logfire client for zig
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: escape non-ASCII bytes in string attributes

- escape bytes 0x80-0xFF as \u00XX to ensure valid JSON
- escape DEL (0x7f) with other control characters
- add tests for string attributes with special chars and raw bytes
- update example to demonstrate string attribute handling

fixes "invalid unicode code point" errors when attribute values
contain invalid UTF-8 or non-ASCII bytes from URL-decoded data.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

zzstoatzz b792c86b 99142767

+168 -2
+26 -1
examples/basic.zig
··· 25 25 logfire.info("application started", .{}); 26 26 logfire.debug("debug message with value: {d}", .{42}); 27 27 28 - // span with timing 28 + // span with timing and string attributes 29 29 { 30 30 const s = logfire.span("example.work", .{ 31 31 .iteration = @as(i64, 1), 32 + .query = "prefect python", 33 + .tag = "python", 32 34 }); 33 35 defer s.end(); 34 36 ··· 36 38 std.posix.nanosleep(0, 10 * std.time.ns_per_ms); 37 39 38 40 logfire.info("work completed", .{}); 41 + } 42 + 43 + // span with dynamic string (simulating arena-allocated query) 44 + { 45 + var buf: [64]u8 = undefined; 46 + const query = std.fmt.bufPrint(&buf, "search query {d}", .{42}) catch "fallback"; 47 + 48 + const s = logfire.span("example.dynamic_string", .{ 49 + .query = query, 50 + }); 51 + defer s.end(); 52 + 53 + std.posix.nanosleep(0, 5 * std.time.ns_per_ms); 54 + } 55 + 56 + // span with special characters that might come from URL params 57 + { 58 + const s = logfire.span("example.special_chars", .{ 59 + .query = "hello \"world\" & <test>", 60 + .unicode = "café résumé naïve", 61 + .control = "tab\there\nnewline", 62 + }); 63 + defer s.end(); 39 64 } 40 65 41 66 // nested spans
+142 -1
src/exporter.zig
··· 655 655 656 656 /// write a string value with proper JSON escaping 657 657 /// uses raw mode to maintain Stringify state while writing manually 658 + /// escapes control chars and non-ASCII bytes to ensure valid JSON 658 659 fn writeStringValue(jw: *json.Stringify, s: []const u8) !void { 659 660 try jw.beginWriteRaw(); 660 661 try jw.writer.writeByte('"'); 661 662 for (s) |c| { 662 663 switch (c) { 664 + // characters that need escaping in JSON 663 665 '"' => try jw.writer.writeAll("\\\""), 664 666 '\\' => try jw.writer.writeAll("\\\\"), 665 667 '\n' => try jw.writer.writeAll("\\n"), 666 668 '\r' => try jw.writer.writeAll("\\r"), 667 669 '\t' => try jw.writer.writeAll("\\t"), 668 - 0x00...0x08, 0x0b, 0x0c, 0x0e...0x1f => { 670 + // control characters (0x00-0x1f except \n \r \t, plus DEL 0x7f) 671 + 0x00...0x08, 0x0b, 0x0c, 0x0e...0x1f, 0x7f => { 669 672 var buf: [6]u8 = undefined; 670 673 _ = std.fmt.bufPrint(&buf, "\\u00{x:0>2}", .{c}) catch unreachable; 671 674 try jw.writer.writeAll(&buf); 672 675 }, 676 + // non-ASCII bytes (0x80-0xFF) - escape to avoid invalid UTF-8 issues 677 + 0x80...0xff => { 678 + var buf: [6]u8 = undefined; 679 + _ = std.fmt.bufPrint(&buf, "\\u00{x:0>2}", .{c}) catch unreachable; 680 + try jw.writer.writeAll(&buf); 681 + }, 682 + // printable ASCII (0x20-0x7e except " and \) - safe to pass through 673 683 else => try jw.writer.writeByte(c), 674 684 } 675 685 } ··· 748 758 try std.testing.expect(std.mem.indexOf(u8, json_out, "test.span") != null); 749 759 try std.testing.expect(std.mem.indexOf(u8, json_out, "test-service") != null); 750 760 } 761 + 762 + test "buildTracesJson with string attributes" { 763 + const allocator = std.testing.allocator; 764 + var config = Config{ 765 + .service_name = "test-service", 766 + .send_to_logfire = .no, 767 + }; 768 + config = config.resolve(); 769 + 770 + var exporter = Exporter.init(allocator, config); 771 + defer exporter.deinit(); 772 + 773 + // create span with string attributes 774 + var span_data = Span.Data{ 775 + .name = "test.span", 776 + .trace_id = [_]u8{0x01} ** 16, 777 + .span_id = [_]u8{0x02} ** 8, 778 + .start_time_ns = 1000, 779 + .end_time_ns = 2000, 780 + }; 781 + 782 + // add string attributes manually 783 + span_data.attribute_count = Attribute.fromStruct(.{ 784 + .query = "hello world", 785 + .tag = "python", 786 + }, &span_data.attributes); 787 + 788 + const spans = [_]Span.Data{span_data}; 789 + const json_out = try exporter.buildTracesJson(&spans); 790 + defer allocator.free(json_out); 791 + 792 + 793 + // verify attributes are present 794 + try std.testing.expect(std.mem.indexOf(u8, json_out, "\"query\"") != null); 795 + try std.testing.expect(std.mem.indexOf(u8, json_out, "\"hello world\"") != null); 796 + try std.testing.expect(std.mem.indexOf(u8, json_out, "\"tag\"") != null); 797 + try std.testing.expect(std.mem.indexOf(u8, json_out, "\"python\"") != null); 798 + 799 + // parse with std.json to verify it's valid JSON 800 + const parsed = std.json.parseFromSlice(std.json.Value, allocator, json_out, .{}) catch |err| { 801 + std.debug.print("JSON parse error: {}\n", .{err}); 802 + return err; 803 + }; 804 + defer parsed.deinit(); 805 + } 806 + 807 + test "buildTracesJson with special characters" { 808 + const allocator = std.testing.allocator; 809 + var config = Config{ 810 + .service_name = "test-service", 811 + .send_to_logfire = .no, 812 + }; 813 + config = config.resolve(); 814 + 815 + var exporter = Exporter.init(allocator, config); 816 + defer exporter.deinit(); 817 + 818 + var span_data = Span.Data{ 819 + .name = "test.span", 820 + .trace_id = [_]u8{0x01} ** 16, 821 + .span_id = [_]u8{0x02} ** 8, 822 + .start_time_ns = 1000, 823 + .end_time_ns = 2000, 824 + }; 825 + 826 + // test various special characters 827 + span_data.attribute_count = Attribute.fromStruct(.{ 828 + .query = "hello \"world\" & <test>", 829 + .unicode = "café résumé", 830 + .control = "tab\there", 831 + .backslash = "path\\to\\file", 832 + .newline = "line1\nline2", 833 + }, &span_data.attributes); 834 + 835 + const spans = [_]Span.Data{span_data}; 836 + const json_out = try exporter.buildTracesJson(&spans); 837 + defer allocator.free(json_out); 838 + 839 + 840 + // verify escapes are present 841 + try std.testing.expect(std.mem.indexOf(u8, json_out, "\\\"world\\\"") != null); // escaped quotes 842 + try std.testing.expect(std.mem.indexOf(u8, json_out, "\\\\to\\\\") != null); // escaped backslashes 843 + try std.testing.expect(std.mem.indexOf(u8, json_out, "\\t") != null); // escaped tab 844 + try std.testing.expect(std.mem.indexOf(u8, json_out, "\\n") != null); // escaped newline 845 + 846 + // verify it parses as valid JSON 847 + const parsed = std.json.parseFromSlice(std.json.Value, allocator, json_out, .{}) catch |err| { 848 + std.debug.print("JSON parse error: {}\n", .{err}); 849 + return err; 850 + }; 851 + defer parsed.deinit(); 852 + } 853 + 854 + test "buildTracesJson with raw bytes" { 855 + const allocator = std.testing.allocator; 856 + var config = Config{ 857 + .service_name = "test-service", 858 + .send_to_logfire = .no, 859 + }; 860 + config = config.resolve(); 861 + 862 + var exporter = Exporter.init(allocator, config); 863 + defer exporter.deinit(); 864 + 865 + var span_data = Span.Data{ 866 + .name = "test.span", 867 + .trace_id = [_]u8{0x01} ** 16, 868 + .span_id = [_]u8{0x02} ** 8, 869 + .start_time_ns = 1000, 870 + .end_time_ns = 2000, 871 + }; 872 + 873 + // test raw bytes that might come from malformed URL decoding 874 + // these include invalid UTF-8 sequences 875 + span_data.attribute_count = Attribute.fromStruct(.{ 876 + .query = "test\x80\x81\x82value", // invalid UTF-8 bytes 877 + .ctrl = "null\x00byte", // null byte 878 + }, &span_data.attributes); 879 + 880 + const spans = [_]Span.Data{span_data}; 881 + const json_out = try exporter.buildTracesJson(&spans); 882 + defer allocator.free(json_out); 883 + 884 + 885 + // now that we escape non-ASCII, this should parse successfully 886 + const parsed = std.json.parseFromSlice(std.json.Value, allocator, json_out, .{}) catch |err| { 887 + std.debug.print("JSON parse error: {}\n", .{err}); 888 + return err; 889 + }; 890 + defer parsed.deinit(); 891 + }