Skip to content

Commit a523813

Browse files
feat: stamp service.instance.id on spans and share it with the heartbeat (#24)
1 parent 205c03d commit a523813

6 files changed

Lines changed: 100 additions & 17 deletions

File tree

src/client.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { randomUUID } from "node:crypto";
12
import { type Tracer, trace } from "@opentelemetry/api";
23
// The -proto exporter (OTLP protobuf over HTTP), matching the Python SDK's
34
// opentelemetry-exporter-otlp-proto-http. The Rius ingest accepts only
@@ -20,7 +21,7 @@ import { HeartbeatSender, type HeartbeatTransport, OpenRootSpanTracker } from ".
2021
import { enableInstrumentations } from "./instrumentation.js";
2122
import { MaskingSpanExporter } from "./masking.js";
2223
import { PendingSpanProcessor } from "./pending.js";
23-
import { TRACER_NAME } from "./semconv.js";
24+
import { SERVICE_INSTANCE_ID, TRACER_NAME } from "./semconv.js";
2425
import { SessionSpanProcessor } from "./session.js";
2526

2627
/** Options accepted by {@link init}, extending the shared configuration. */
@@ -203,8 +204,16 @@ export function init(options: InitOptions = {}): RiusClient {
203204
processors.add(batch);
204205
}
205206

207+
// One identity per client lifetime, shared by spans (resource) and
208+
// heartbeats (payload instance_id) so the backend can join them and count
209+
// replicas. Workers spawned after init() (cluster/fork patterns) should
210+
// init() themselves for exact per-worker span identity.
211+
const instanceId = randomUUID();
206212
const provider = new NodeTracerProvider({
207-
resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: config.serviceName }),
213+
resource: resourceFromAttributes({
214+
[ATTR_SERVICE_NAME]: config.serviceName,
215+
[SERVICE_INSTANCE_ID]: instanceId,
216+
}),
208217
// Always ParentBased, with no AlwaysOn shortcut at rate 1. They are not
209218
// equivalent: ParentBased honours a remote UNSAMPLED parent and drops,
210219
// while AlwaysOn records regardless, producing children of a span the
@@ -245,6 +254,7 @@ export function init(options: InitOptions = {}): RiusClient {
245254
headers: authHeaders,
246255
intervalMs: config.heartbeatIntervalMs,
247256
agentName: config.agentName,
257+
instanceId,
248258
tracker,
249259
transport: options.heartbeatTransport,
250260
});

src/heartbeat.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { randomUUID } from "node:crypto";
21
import { createRequire } from "node:module";
32
import type { Context } from "@opentelemetry/api";
43
import type { ReadableSpan, Span, SpanProcessor } from "@opentelemetry/sdk-trace-base";
@@ -122,6 +121,12 @@ export interface HeartbeatSenderOptions {
122121
headers: Record<string, string>;
123122
intervalMs: number;
124123
agentName: string;
124+
/**
125+
* Identity of one process lifetime, injected by init(): the same value
126+
* rides every span as the `service.instance.id` resource attribute, which
127+
* is what lets the backend join heartbeats to traces.
128+
*/
129+
instanceId: string;
125130
tracker: OpenRootSpanTracker;
126131
/** Injected transport for tests; defaults to the fetch-based HTTP POST above. */
127132
transport?: HeartbeatTransport;
@@ -131,8 +136,8 @@ export interface HeartbeatSenderOptions {
131136

132137
/** Pings the heartbeat endpoint for the process lifetime; see module docs for the contract. */
133138
export class HeartbeatSender {
134-
/** Identity of one process lifetime, fresh per sender. */
135-
readonly instanceId = randomUUID();
139+
/** Identity of one process lifetime; injected, shared with span resources. */
140+
readonly instanceId: string;
136141

137142
private readonly agentName: string;
138143
private readonly tracker: OpenRootSpanTracker;
@@ -146,6 +151,7 @@ export class HeartbeatSender {
146151
private deliveryWarned = false;
147152

148153
constructor(opts: HeartbeatSenderOptions) {
154+
this.instanceId = opts.instanceId;
149155
this.agentName = opts.agentName;
150156
this.tracker = opts.tracker;
151157
this.intervalMs = opts.intervalMs;

src/semconv.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
/** Wire-visible instrumentation scope name. The backend keys on this value. */
22
export const TRACER_NAME = "glassflow";
33

4+
// OTel standard resource attribute: identity of one process lifetime (one
5+
// uuid per client, minted at init). The heartbeat payload's instance_id
6+
// carries the SAME value, which is what lets the backend join heartbeats to
7+
// traces and count replicas.
8+
export const SERVICE_INSTANCE_ID = "service.instance.id";
9+
410
// OpenInference
511
export const OPENINFERENCE_SPAN_KIND = "openinference.span.kind";
612
export const INPUT_VALUE = "input.value";

tests/client.test.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
44
import { type InitOptions, RiusClient, getTracer, init } from "../src/client.js";
55
import type { HeartbeatTransport } from "../src/heartbeat.js";
66
import { REGISTRY } from "../src/instrumentation.js";
7-
import { GLASSFLOW_SPAN_PENDING } from "../src/semconv.js";
7+
import { GLASSFLOW_SPAN_PENDING, SERVICE_INSTANCE_ID } from "../src/semconv.js";
88
import { startAsCurrentSpan } from "../src/spans.js";
99

1010
let client: RiusClient | undefined;
@@ -313,3 +313,59 @@ describe("init: partial spans", () => {
313313
expect(exporter.getFinishedSpans()).toHaveLength(0);
314314
});
315315
});
316+
317+
describe("instance identity (service.instance.id)", () => {
318+
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
319+
320+
it("stamps a uuid as service.instance.id on the resource of every span", async () => {
321+
const exporter = new InMemorySpanExporter();
322+
client = testInit({ serviceName: "svc", spanExporter: exporter });
323+
getTracer().startSpan("s").end();
324+
await client.flush();
325+
const id = exporter.getFinishedSpans()[0].resource.attributes[SERVICE_INSTANCE_ID];
326+
expect(id).toMatch(UUID_PATTERN);
327+
});
328+
329+
it("sends the same id on heartbeat payloads and span resources", async () => {
330+
const payloads: Record<string, unknown>[] = [];
331+
const exporter = new InMemorySpanExporter();
332+
client = init({
333+
serviceName: "svc",
334+
spanExporter: exporter,
335+
heartbeatTransport: async (payload) => {
336+
payloads.push(payload);
337+
},
338+
});
339+
getTracer().startSpan("s").end();
340+
await client.flush();
341+
const resourceId = exporter.getFinishedSpans()[0].resource.attributes[SERVICE_INSTANCE_ID];
342+
expect(payloads.length).toBeGreaterThan(0);
343+
expect(payloads[0]?.instance_id).toBe(resourceId);
344+
});
345+
346+
it("stamps the id even with the heartbeat disabled", async () => {
347+
const exporter = new InMemorySpanExporter();
348+
client = testInit({ serviceName: "svc", heartbeat: false, spanExporter: exporter });
349+
getTracer().startSpan("s").end();
350+
await client.flush();
351+
const id = exporter.getFinishedSpans()[0].resource.attributes[SERVICE_INSTANCE_ID];
352+
expect(id).toMatch(UUID_PATTERN);
353+
});
354+
355+
it("gives two separately initialized clients distinct ids", async () => {
356+
const exporterA = new InMemorySpanExporter();
357+
client = testInit({ serviceName: "svc", spanExporter: exporterA });
358+
getTracer().startSpan("a").end();
359+
await client.flush();
360+
const idA = exporterA.getFinishedSpans()[0].resource.attributes[SERVICE_INSTANCE_ID];
361+
await client.shutdown();
362+
363+
const exporterB = new InMemorySpanExporter();
364+
client = testInit({ serviceName: "svc", spanExporter: exporterB });
365+
getTracer().startSpan("b").end();
366+
await client.flush();
367+
const idB = exporterB.getFinishedSpans()[0].resource.attributes[SERVICE_INSTANCE_ID];
368+
369+
expect(idA).not.toBe(idB);
370+
});
371+
});

tests/heartbeat.test.ts

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ describe("HeartbeatSender", () => {
8888
headers: {},
8989
intervalMs: 15_000,
9090
agentName: "my-agent",
91+
instanceId: "test-instance",
9192
tracker: fakeTracker(["trace-1"]),
9293
transport,
9394
});
@@ -109,25 +110,20 @@ describe("HeartbeatSender", () => {
109110
expect(timeoutMs).toBe(3000);
110111
});
111112

112-
it("has a fresh instanceId per sender", () => {
113+
it("carries the injected instanceId, the same identity spans get as service.instance.id", () => {
113114
const transport = vi.fn<HeartbeatTransport>().mockResolvedValue(undefined);
114-
const a = new HeartbeatSender({
115+
const sender = new HeartbeatSender({
115116
url: "http://example.invalid",
116117
headers: {},
117118
intervalMs: 15_000,
118119
agentName: "a",
120+
instanceId: "injected-identity",
119121
tracker: fakeTracker([]),
120122
transport,
121123
});
122-
const b = new HeartbeatSender({
123-
url: "http://example.invalid",
124-
headers: {},
125-
intervalMs: 15_000,
126-
agentName: "b",
127-
tracker: fakeTracker([]),
128-
transport,
129-
});
130-
expect(a.instanceId).not.toBe(b.instanceId);
124+
sender.start();
125+
expect(sender.instanceId).toBe("injected-identity");
126+
expect(transport.mock.calls[0]?.[0]?.instance_id).toBe("injected-identity");
131127
});
132128

133129
it("pings again on every interval tick", () => {
@@ -137,6 +133,7 @@ describe("HeartbeatSender", () => {
137133
headers: {},
138134
intervalMs: 15_000,
139135
agentName: "my-agent",
136+
instanceId: "test-instance",
140137
tracker: fakeTracker([]),
141138
transport,
142139
});
@@ -159,6 +156,7 @@ describe("HeartbeatSender", () => {
159156
headers: {},
160157
intervalMs: 15_000,
161158
agentName: "my-agent",
159+
instanceId: "test-instance",
162160
tracker: fakeTracker(traceIds),
163161
transport,
164162
});
@@ -179,6 +177,7 @@ describe("HeartbeatSender", () => {
179177
headers: {},
180178
intervalMs: 15_000,
181179
agentName: "my-agent",
180+
instanceId: "test-instance",
182181
tracker: fakeTracker([]),
183182
transport,
184183
});
@@ -203,6 +202,7 @@ describe("HeartbeatSender", () => {
203202
headers: {},
204203
intervalMs: 15_000,
205204
agentName: "my-agent",
205+
instanceId: "test-instance",
206206
tracker: fakeTracker([]),
207207
transport,
208208
});
@@ -220,6 +220,7 @@ describe("HeartbeatSender", () => {
220220
headers: {},
221221
intervalMs: 15_000,
222222
agentName: "my-agent",
223+
instanceId: "test-instance",
223224
tracker: fakeTracker([]),
224225
transport,
225226
});
@@ -240,6 +241,7 @@ describe("HeartbeatSender", () => {
240241
headers: {},
241242
intervalMs: 15_000,
242243
agentName: "my-agent",
244+
instanceId: "test-instance",
243245
tracker: fakeTracker([]),
244246
transport,
245247
});
@@ -305,6 +307,7 @@ describe("default HTTP transport", () => {
305307
headers: { authorization: "Bearer test-key" },
306308
intervalMs: 15_000,
307309
agentName: "my-agent",
310+
instanceId: "test-instance",
308311
tracker: fakeTracker([]),
309312
});
310313
sender.start();
@@ -332,6 +335,7 @@ describe("default HTTP transport", () => {
332335
headers: {},
333336
intervalMs: 15_000,
334337
agentName: "my-agent",
338+
instanceId: "test-instance",
335339
tracker: fakeTracker([]),
336340
});
337341
sender.start();

tests/heartbeatConfigEdges.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ it("(3) caps open_traces at 32 with 40 REAL root spans, count reports 40", async
4545
headers: {},
4646
intervalMs: 999999,
4747
agentName: "a",
48+
instanceId: "test-instance",
4849
tracker,
4950
transport: async (p) => void payloads.push(p),
5051
});

0 commit comments

Comments
 (0)