pi-mono/packages/telemetry/test/telemetry.test.ts
Mario Zechner 2ec55d808d feat: Durable and pi-ai telemetry; Context.telemetry is always a span
- pi-ai: AI_TELEMETRY_SCHEMA; pi.ai.request and pi.ai.poll spans around Models requests via a lazyStream observer
  (no provider changes), first_chunk and block events (never per delta), usage/cost end attributes, provider_error
  status with a bounded message, aborted stays ok; duration, first-chunk, token and cost metrics; option renamed
  telemetryContext -> telemetry (a parent span)
- pi-telemetry: TelemetryContext carries readonly metrics and propagation capabilities on every span
- chord/context: Context.telemetry is always a TelemetrySpan (NOOP default); withTelemetry takes spans
- durable: DURABLE_TELEMETRY_SCHEMA; pi.durable.submit (inputs start their own trace linked to the caller) and
  pi.durable.phase spans for every task phase; stored carriers on submissions and tasks (TaskOptions.trace, links);
  entry, submission, checkpoint, replay and report events after commit; resumed tasks stay in their trace; phase
  duration, active task and report metrics; failed phases carry fixed categories with bounded messages
2026-10-19 06:26:02 +00:00

203 lines
7.2 KiB
TypeScript

import { describe, expect, expectTypeOf, it } from "vitest";
import {
createTypedSpanStarter,
defineTelemetrySchema,
InMemoryTelemetryContext,
NOOP_TELEMETRY_CONTEXT,
NOOP_TELEMETRY_METRICS,
NOOP_TELEMETRY_PROPAGATION,
type SchemaTelemetrySpan,
type SpanAttributes,
type SpanOptions,
type SpanStatus,
type TelemetrySchemaSpanStartAttributes,
type TelemetrySpan,
} from "../src/index.ts";
function unreadable<T extends object>(value: T): T {
return new Proxy(value, {
get: () => {
throw new Error("read");
},
getOwnPropertyDescriptor: () => {
throw new Error("inspect");
},
ownKeys: () => {
throw new Error("enumerate");
},
});
}
describe("telemetry schemas", () => {
it("preserves serializable definitions and infers exact attributes", () => {
const definition = {
version: 1,
spans: {
operation: {
description: "Test operation",
parents: { kind: "any" },
startAttributes: {
kind: { type: "string", required: true, values: ["read", "write"], description: "Kind" },
},
endAttributes: {},
events: {
result: {
description: "Result",
attributes: {
outcome: { type: "string", required: true, values: ["ok", "error"], description: "Outcome" },
},
},
},
status: { default: "ok", errorWhen: "The operation fails" },
},
},
} as const;
const schema = defineTelemetrySchema(definition);
expect(schema).toBe(definition);
expect(() => JSON.stringify(schema)).not.toThrow();
expectTypeOf<TelemetrySchemaSpanStartAttributes<typeof schema, "operation">>().toMatchTypeOf<{
kind: "read" | "write";
}>();
const compileTimeFailures = (span: SchemaTelemetrySpan<typeof schema, "operation">) => {
span.addEvent("result", { outcome: "ok" });
// @ts-expect-error required event attributes cannot be omitted
span.addEvent("result");
// @ts-expect-error closed-set event values are exact
span.addEvent("result", { outcome: "other" });
// @ts-expect-error undeclared events are rejected
span.addEvent("unknown", {});
// @ts-expect-error empty end schemas reject every attribute
span.setAttributes({ unknown: true });
};
expectTypeOf(compileTimeFailures).toBeFunction();
});
it("combines schema vocabularies and binds child starters to their parent spans", async () => {
const operationSchema = defineTelemetrySchema({
version: 1,
spans: {
operation: {
description: "Operation",
parents: { kind: "root_or_external" },
startAttributes: {
kind: { type: "string", required: true, values: ["read", "write"], description: "Kind" },
},
endAttributes: {},
status: { default: "ok", errorWhen: "The operation fails" },
},
},
} as const);
const requestSchema = defineTelemetrySchema({
version: 3,
spans: {
request: {
description: "Request",
parents: { kind: "spans", spans: ["operation"] },
startAttributes: {
provider: { type: "string", required: true, description: "Provider" },
},
endAttributes: {
response: { type: "string", description: "Response kind" },
},
status: { default: "ok", errorWhen: "The request fails" },
},
},
} as const);
const telemetryContext = new InMemoryTelemetryContext();
const startSpan = createTypedSpanStarter(telemetryContext, [operationSchema, requestSchema]);
const result = await startSpan("operation", { kind: "read" }, (_operationSpan, startChildSpan) =>
startChildSpan("request", { provider: "example" }, (requestSpan) => {
requestSpan.setAttributes({ response: "cached" });
return 42;
}),
);
expect(result).toBe(42);
expectTypeOf(result).toEqualTypeOf<number>();
const spans = telemetryContext.getSpans();
const operationSpan = spans.find((span) => span.name === "operation");
const requestSpan = spans.find((span) => span.name === "request");
expect(operationSpan?.parentId).toBeNull();
expect(requestSpan?.parentId).toBe(operationSpan?.id);
expect(() =>
createTypedSpanStarter(telemetryContext, unreadable([operationSchema, requestSchema] as const)),
).not.toThrow();
const syncError = { kind: "sync" };
const syncResult = startSpan("operation", { kind: "write" }, () => {
throw syncError;
});
await expect(syncResult).rejects.toBe(syncError);
const asyncError = { kind: "async" };
const asyncResult = startSpan("request", { provider: "example" }, async () => {
throw asyncError;
});
await expect(asyncResult).rejects.toBe(asyncError);
const compileTimeFailures = () => {
const spanName: "operation" | "request" = Math.random() > 0.5 ? "operation" : "request";
// @ts-expect-error union-valued names must be narrowed to preserve name and attribute correlation
void startSpan(spanName, { kind: "read" }, () => {});
const extraRequestAttributes = { provider: "example", unknown: true } as const;
// @ts-expect-error variables with unknown attributes are rejected
void startSpan("request", extraRequestAttributes, () => {});
// @ts-expect-error unknown span names are rejected across the combined vocabulary
void startSpan("unknown", {}, () => {});
// @ts-expect-error attributes are selected from the schema that owns the span
void startSpan("request", { kind: "read" }, () => {});
// @ts-expect-error duplicate span names across schemas are rejected
void createTypedSpanStarter(telemetryContext, [operationSchema, operationSchema]);
};
expectTypeOf(compileTimeFailures).toBeFunction();
});
});
describe("NOOP_TELEMETRY_CONTEXT", () => {
it("admits callbacks synchronously and reuses one inert span", async () => {
let admitted = false;
let firstSpan: TelemetrySpan | undefined;
const result = NOOP_TELEMETRY_CONTEXT.startSpan({ name: "first" }, async (span) => {
admitted = true;
firstSpan = span;
const child = await span.startSpan({ name: "child" }, (childSpan) => childSpan);
expect(child).toBe(span);
return 42;
});
expect(admitted).toBe(true);
expect(await result).toBe(42);
expect(Object.isFrozen(firstSpan)).toBe(true);
expect(NOOP_TELEMETRY_CONTEXT.metrics).toBe(NOOP_TELEMETRY_METRICS);
expect(NOOP_TELEMETRY_CONTEXT.propagation).toBe(NOOP_TELEMETRY_PROPAGATION);
expect(firstSpan?.metrics).toBe(NOOP_TELEMETRY_METRICS);
expect(firstSpan?.propagation).toBe(NOOP_TELEMETRY_PROPAGATION);
});
it("preserves synchronous and asynchronous rejection values", async () => {
const syncError = { kind: "sync" };
const sync = NOOP_TELEMETRY_CONTEXT.startSpan({ name: "sync" }, () => {
throw syncError;
});
await expect(sync).rejects.toBe(syncError);
const asyncError = { kind: "async" };
const asyncResult = NOOP_TELEMETRY_CONTEXT.startSpan({ name: "async" }, async () => {
throw asyncError;
});
await expect(asyncResult).rejects.toBe(asyncError);
});
it("does not inspect or retain telemetry payloads", async () => {
const options = unreadable<SpanOptions>({ name: "operation", attributes: { secret: "prompt content" } });
await NOOP_TELEMETRY_CONTEXT.startSpan(options, (span) => {
const attributes = unreadable<SpanAttributes>({ secret: "content" });
const status = unreadable<SpanStatus>({ status: "ok" });
expect(() => span.addEvent("event", attributes)).not.toThrow();
expect(() => span.setAttributes(attributes)).not.toThrow();
expect(() => span.setStatus(status)).not.toThrow();
});
});
});