logfire client for zig
0
fork

Configure Feed

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

fix: serialize metric values as numbers, not strings

the OTLP JSON format expects asInt/asDouble and histogram counts
to be numbers, not strings. only timestamps are strings.

compared against official examples in opentelemetry-proto repo.

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

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

zzstoatzz 01f463d8 3e05dab1

+64 -17
+64 -17
src/exporter.zig
··· 109 109 }) catch return error.RequestFailed; 110 110 111 111 if (result.status != .ok and result.status != .accepted) { 112 - std.log.warn("logfire: export failed: {s}", .{aw.toArrayList().items}); 112 + std.log.warn("logfire: export to {s} failed ({s}): {s}", .{ path, @tagName(result.status), aw.toArrayList().items }); 113 113 return error.SendFailed; 114 + } 115 + 116 + // OTLP partial_success response indicates rejected data points 117 + const response = aw.toArrayList().items; 118 + if (response.len > 0) { 119 + std.log.debug("logfire: {s} response: {s}", .{ path, response }); 114 120 } 115 121 } 116 122 ··· 202 208 return output.toOwnedSlice(); 203 209 } 204 210 205 - fn buildMetricsJson(self: *Exporter, metrics: []const MetricData) ![]u8 { 211 + pub fn buildMetricsJson(self: *Exporter, metrics: []const MetricData) ![]u8 { 206 212 var output: std.Io.Writer.Allocating = .init(self.allocator); 207 213 errdefer output.deinit(); 208 214 var jw: json.Stringify = .{ .writer = &output.writer }; ··· 431 437 fn writeNumberDataPointObject(jw: *json.Stringify, dp: metrics_mod.NumberDataPoint) !void { 432 438 try jw.beginObject(); 433 439 440 + // attributes - always include even if empty 441 + try jw.objectField("attributes"); 442 + try jw.beginArray(); 443 + for (dp.attributes) |attr| { 444 + try writeAttributeFromAttr(jw, attr); 445 + } 446 + try jw.endArray(); 447 + 434 448 try jw.objectField("startTimeUnixNano"); 435 449 try writeNsString(jw, dp.start_time_ns); 436 450 ··· 440 454 switch (dp.value) { 441 455 .int => |v| { 442 456 try jw.objectField("asInt"); 443 - try writeIntString(jw, v); 457 + try jw.write(v); // asInt is a number, not a string 444 458 }, 445 459 .double => |v| { 446 460 try jw.objectField("asDouble"); ··· 448 462 }, 449 463 } 450 464 451 - if (dp.attributes.len > 0) { 452 - try jw.objectField("attributes"); 453 - try jw.beginArray(); 454 - for (dp.attributes) |attr| { 455 - try writeAttributeFromAttr(jw, attr); 456 - } 457 - try jw.endArray(); 458 - } 459 - 460 465 try jw.endObject(); 461 466 } 462 467 ··· 470 475 try writeNsString(jw, dp.time_ns); 471 476 472 477 try jw.objectField("count"); 473 - try writeIntString(jw, @intCast(dp.count)); 478 + try jw.write(dp.count); // number, not string 474 479 475 480 try jw.objectField("sum"); 476 481 try jw.write(dp.sum); ··· 478 483 try jw.objectField("bucketCounts"); 479 484 try jw.beginArray(); 480 485 for (dp.bucket_counts) |c| { 481 - try writeIntString(jw, @intCast(c)); 486 + try jw.write(c); // numbers, not strings 482 487 } 483 488 try jw.endArray(); 484 489 ··· 517 522 try writeNsString(jw, dp.time_ns); 518 523 519 524 try jw.objectField("count"); 520 - try writeIntString(jw, @intCast(dp.count)); 525 + try jw.write(dp.count); // number, not string 521 526 522 527 try jw.objectField("sum"); 523 528 try jw.write(dp.sum); ··· 526 531 try jw.write(@as(i64, dp.scale)); 527 532 528 533 try jw.objectField("zeroCount"); 529 - try writeIntString(jw, @intCast(dp.zero_count)); 534 + try jw.write(dp.zero_count); // number, not string 530 535 531 536 try jw.objectField("positive"); 532 537 try jw.beginObject(); ··· 535 540 try jw.objectField("bucketCounts"); 536 541 try jw.beginArray(); 537 542 for (dp.positive_bucket_counts) |c| { 538 - try writeIntString(jw, @intCast(c)); 543 + try jw.write(c); // numbers, not strings 539 544 } 540 545 try jw.endArray(); 541 546 try jw.endObject(); ··· 644 649 } 645 650 646 651 // tests 652 + 653 + test "buildMetricsJson" { 654 + const allocator = std.testing.allocator; 655 + var config = Config{ 656 + .service_name = "test-service", 657 + .send_to_logfire = .no, 658 + }; 659 + config = config.resolve(); 660 + 661 + var exporter = Exporter.init(allocator, config); 662 + defer exporter.deinit(); 663 + 664 + var dp = metrics_mod.NumberDataPoint{ 665 + .start_time_ns = 1000000000000000000, 666 + .time_ns = 1000000000000000000, 667 + .value = .{ .int = 42 }, 668 + }; 669 + 670 + const metrics = [_]MetricData{ 671 + .{ 672 + .name = "test.counter", 673 + .description = "A test counter", 674 + .unit = "1", 675 + .data = .{ 676 + .sum = .{ 677 + .data_points = @as(*const [1]metrics_mod.NumberDataPoint, &dp), 678 + .temporality = .delta, 679 + .is_monotonic = true, 680 + }, 681 + }, 682 + }, 683 + }; 684 + 685 + const json_out = try exporter.buildMetricsJson(&metrics); 686 + defer allocator.free(json_out); 687 + 688 + // verify structure 689 + try std.testing.expect(std.mem.indexOf(u8, json_out, "test.counter") != null); 690 + try std.testing.expect(std.mem.indexOf(u8, json_out, "\"aggregationTemporality\":1") != null); // delta=1 691 + try std.testing.expect(std.mem.indexOf(u8, json_out, "\"isMonotonic\":true") != null); 692 + try std.testing.expect(std.mem.indexOf(u8, json_out, "\"asInt\":42") != null); 693 + } 647 694 648 695 test "buildTracesJson" { 649 696 const allocator = std.testing.allocator;