| 1 | import assert from "node:assert/strict"; |
| 2 | import test from "node:test"; |
| 3 | |
| 4 | import { decodeLogs, decodeMetrics, decodeTraces, ProtoError } from "./proto.ts"; |
| 5 | |
| 6 | // A small protobuf writer, enough to build the messages SDKs send. |
| 7 | const varint = (n: bigint | number): number[] => { |
| 8 | let v = BigInt(n); |
| 9 | if (v < 0n) v = BigInt.asUintN(64, v); |
| 10 | const out: number[] = []; |
| 11 | do { |
| 12 | let byte = Number(v & 0x7fn); |
| 13 | v >>= 7n; |
| 14 | if (v > 0n) byte |= 0x80; |
| 15 | out.push(byte); |
| 16 | } while (v > 0n); |
| 17 | return out; |
| 18 | }; |
| 19 | const key = (no: number, wire: number) => varint((no << 3) | wire); |
| 20 | const bytes = (no: number, b: number[] | Uint8Array) => [...key(no, 2), ...varint(b.length), ...b]; |
| 21 | const str = (no: number, s: string) => bytes(no, [...new TextEncoder().encode(s)]); |
| 22 | const msg = (no: number, ...parts: number[][]) => bytes(no, parts.flat()); |
| 23 | const vint = (no: number, n: number | bigint) => [...key(no, 0), ...varint(n)]; |
| 24 | const fixed64 = (no: number, n: bigint) => { |
| 25 | const b = new Uint8Array(8); |
| 26 | new DataView(b.buffer).setBigUint64(0, n, true); |
| 27 | return [...key(no, 1), ...b]; |
| 28 | }; |
| 29 | const double = (no: number, n: number) => { |
| 30 | const b = new Uint8Array(8); |
| 31 | new DataView(b.buffer).setFloat64(0, n, true); |
| 32 | return [...key(no, 1), ...b]; |
| 33 | }; |
| 34 | const hex = (h: string) => h.match(/../g)!.map((x) => parseInt(x, 16)); |
| 35 | const kv = (no: number, k: string, value: number[]) => msg(no, str(1, k), msg(2, value)); |
| 36 | |
| 37 | const TRACE = "4bf92f3577b34da6a3ce929d0e0e4736"; |
| 38 | const SPAN = "00f067aa0ba902b7"; |
| 39 | |
| 40 | test("a trace export in protobuf reads as OTLP/JSON does", () => { |
| 41 | const span = [ |
| 42 | ...bytes(1, hex(TRACE)), |
| 43 | ...bytes(2, hex(SPAN)), |
| 44 | ...str(5, "GET /users/:id"), |
| 45 | ...vint(6, 2), |
| 46 | ...fixed64(7, 1_760_000_000_000_000_000n), |
| 47 | ...fixed64(8, 1_760_000_000_250_000_000n), |
| 48 | ...kv(9, "http.response.status_code", vint(3, 200)), |
| 49 | ...kv(9, "http.route", str(1, "/users/:id")), |
| 50 | ...kv(9, "ratio", double(4, 0.5)), |
| 51 | ...kv(9, "ok", vint(2, 1)), |
| 52 | ...kv(9, "neg", vint(3, -5n)), |
| 53 | ...msg(11, fixed64(1, 1_760_000_000_100_000_000n), str(2, "exception"), kv(3, "exception.type", str(1, "TypeError"))), |
| 54 | ...msg(15, str(2, "boom"), vint(3, 2)), |
| 55 | // A field this reader does not know is skipped. |
| 56 | ...vint(99, 7), |
| 57 | ]; |
| 58 | const request = msg(1, msg(1, kv(1, "service.name", str(1, "api"))), msg(2, msg(1, str(1, "scope")), msg(2, span))); |
| 59 | const out = decodeTraces(Uint8Array.from(request)); |
| 60 | const s = out.resourceSpans![0].scopeSpans![0].spans![0]; |
| 61 | assert.equal(out.resourceSpans![0].resource!.attributes![0].key, "service.name"); |
| 62 | assert.equal(s.traceId, TRACE); |
| 63 | assert.equal(s.spanId, SPAN); |
| 64 | assert.equal(s.name, "GET /users/:id"); |
| 65 | assert.equal(s.kind, 2); |
| 66 | assert.equal(s.startTimeUnixNano, "1760000000000000000"); |
| 67 | assert.equal(s.endTimeUnixNano, "1760000000250000000"); |
| 68 | assert.deepEqual(s.attributes![0], { key: "http.response.status_code", value: { intValue: "200" } }); |
| 69 | assert.deepEqual(s.attributes![2].value, { doubleValue: 0.5 }); |
| 70 | assert.deepEqual(s.attributes![3].value, { boolValue: true }); |
| 71 | assert.deepEqual(s.attributes![4].value, { intValue: "-5" }); |
| 72 | assert.equal(s.events![0].name, "exception"); |
| 73 | assert.deepEqual(s.status, { message: "boom", code: 2 }); |
| 74 | }); |
| 75 | |
| 76 | test("logs and metrics in protobuf", () => { |
| 77 | const record = [...fixed64(1, 1_760_000_000_000_000_000n), ...vint(2, 17), ...str(3, "ERROR"), ...msg(5, str(1, "it broke")), ...bytes(9, hex(TRACE))]; |
| 78 | const logs = decodeLogs(Uint8Array.from(msg(1, msg(2, msg(2, record))))); |
| 79 | const r = logs.resourceLogs![0].scopeLogs![0].logRecords![0]; |
| 80 | assert.equal(r.severityNumber, 17); |
| 81 | assert.deepEqual(r.body, { stringValue: "it broke" }); |
| 82 | assert.equal(r.traceId, TRACE); |
| 83 | |
| 84 | // A histogram with packed buckets and bounds. |
| 85 | const counts = new Uint8Array(24); |
| 86 | [1n, 2n, 3n].forEach((n, i) => new DataView(counts.buffer).setBigUint64(i * 8, n, true)); |
| 87 | const bounds = new Uint8Array(16); |
| 88 | [10, 100].forEach((n, i) => new DataView(bounds.buffer).setFloat64(i * 8, n, true)); |
| 89 | const point = [...fixed64(3, 1_760_000_000_000_000_000n), ...fixed64(4, 6n), ...double(5, 300), ...bytes(6, counts), ...bytes(7, bounds)]; |
| 90 | const gauge = msg(5, msg(1, fixed64(3, 1_760_000_000_000_000_000n), double(4, 42))); |
| 91 | const metrics = decodeMetrics( |
| 92 | Uint8Array.from(msg(1, msg(2, msg(2, str(1, "http.server.duration"), str(3, "ms"), msg(9, msg(1, point), vint(2, 2))), msg(2, str(1, "queue.depth"), gauge)))), |
| 93 | ); |
| 94 | const [h, g] = metrics.resourceMetrics![0].scopeMetrics![0].metrics!; |
| 95 | assert.equal(h.name, "http.server.duration"); |
| 96 | assert.deepEqual(h.histogram!.dataPoints![0].bucketCounts, ["1", "2", "3"]); |
| 97 | assert.deepEqual(h.histogram!.dataPoints![0].explicitBounds, [10, 100]); |
| 98 | assert.equal(h.histogram!.aggregationTemporality, 2); |
| 99 | assert.equal(g.gauge!.dataPoints![0].asDouble, 42); |
| 100 | }); |
| 101 | |
| 102 | test("a message cut short is refused, not read as garbage", () => { |
| 103 | assert.throws(() => decodeTraces(Uint8Array.from([0x0a, 0x10, 0x01])), ProtoError); |
| 104 | }); |