@quicksortsas/telemetry 0.0.0-stage → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Mario Zechner
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,610 @@
1
- # Temporary Holding Version
1
+ # @quicksortsas/telemetry
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Vendor-neutral telemetry contracts and typed schema utilities for pi packages.
4
+
5
+ This package provides:
6
+
7
+ - an explicit, callback-based `TelemetryContext` / `TelemetrySpan` contract;
8
+ - a shared `NOOP_TELEMETRY_CONTEXT`;
9
+ - opaque cross-process propagation and span links;
10
+ - counters, up-down counters, histograms, and typed metric schemas;
11
+ - a reference `InMemoryTelemetryContext` implementation;
12
+ - serializable schema definitions with inferred TypeScript types;
13
+ - no exporter, global current-span state, or dependency on a telemetry backend.
14
+
15
+ Applications can use the in-memory reference or provide an adapter for OpenTelemetry, Sentry, logs, or another backend. QFactory packages pass telemetry contexts explicitly and define their domain schemas separately.
16
+
17
+ ## Table of Contents
18
+
19
+ - [Installation](#installation)
20
+ - [Telemetry Concepts](#telemetry-concepts)
21
+ - [Core Context API](#core-context-api)
22
+ - [Adapter Contract](#adapter-contract)
23
+ - [Propagation and Span Links](#propagation-and-span-links)
24
+ - [Metrics](#metrics)
25
+ - [No-op Context](#no-op-context)
26
+ - [In-Memory Reference Adapter](#in-memory-reference-adapter)
27
+ - [Stored Telemetry](#stored-telemetry)
28
+ - [Adapter Conformance](#adapter-conformance)
29
+ - [Typed Schemas](#typed-schemas)
30
+ - [Start and Completion Attributes](#start-and-completion-attributes)
31
+ - [Schema Metadata](#schema-metadata)
32
+ - [QFactory Package Integration](#pi-package-integration)
33
+ - [Security and Portability](#security-and-portability)
34
+ - [API Reference](#api-reference)
35
+ - [Development](#development)
36
+ - [License](#license)
37
+
38
+ ## Installation
39
+
40
+ ```bash
41
+ npm install @quicksortsas/telemetry
42
+ ```
43
+
44
+ ## Telemetry Concepts
45
+
46
+ Telemetry describes what a program did while it was running. This package models that work using spans, attributes, events, statuses, and explicit context:
47
+
48
+ | Concept | Plain-language meaning |
49
+ |---|---|
50
+ | **Span** | A timed record of one operation, such as loading an account or making an AI request. It begins before the work and ends when the work finishes. |
51
+ | **Parent and child spans** | Operations can contain smaller operations. A request span might contain a cache lookup and a database query. Together they form a tree showing where time was spent. |
52
+ | **Attribute** | A named fact attached to a span, such as `provider: "openai"`, `cache.hit: true`, or `item_count: 12`. Attributes describe the operation and its result. |
53
+ | **Event** | A named occurrence at a point during a span, such as `retry.scheduled` or `cache.lookup`. Events have no duration and may carry their own attributes. |
54
+ | **Status** | The operation's outcome: `ok` or `error`. An error status may include an error name and message. |
55
+ | **Context** | A handle identifying where new work belongs in the span tree. Starting a span from a context makes it a child of that context. |
56
+
57
+ For example, loading an account could produce this telemetry:
58
+
59
+ ```text
60
+ example.account.load span
61
+ ├─ attributes: account.id=123, found=true facts about the span
62
+ ├─ event: example.cache.lookup occurrence during the span
63
+ │ └─ attribute: cache.hit=false fact about the event
64
+ └─ status: ok final outcome
65
+ ```
66
+
67
+ A span is diagnostic data, not business state. An adapter translates these generic concepts into the corresponding concepts used by OpenTelemetry, Sentry, logs, or another backend.
68
+
69
+ ## Core Context API
70
+
71
+ A `TelemetryContext` starts a span around a callback. The callback receives a `TelemetrySpan`, which is also the explicit parent context for child spans. Contexts optionally expose their adapter's `metrics?: TelemetryMetrics` and `propagation?: TelemetryPropagation` capabilities; child spans and extracted parents carry the same capabilities. A `TelemetryContext` adapter starts root spans; hosts retain this adapter. Invocation contexts and qfactory-ai request options carry a `TelemetrySpan`, never the adapter. A tracing adapter that only implements `startSpan()` is still valid.
72
+
73
+ ```typescript
74
+ import {
75
+ NOOP_TELEMETRY_CONTEXT,
76
+ type TelemetryContext,
77
+ } from '@quicksortsas/telemetry';
78
+
79
+ async function loadAccount(
80
+ accountId: string,
81
+ telemetryContext: TelemetryContext = NOOP_TELEMETRY_CONTEXT,
82
+ ) {
83
+ return telemetryContext.startSpan(
84
+ {
85
+ name: 'example.account.load',
86
+ attributes: { 'example.account.id': accountId },
87
+ },
88
+ async (span) => {
89
+ const account = await readAccount(accountId);
90
+ span.setAttributes({ 'example.account.found': account !== undefined });
91
+ return account;
92
+ },
93
+ );
94
+ }
95
+ ```
96
+
97
+ Pass the callback span to lower-level work to create explicit nesting:
98
+
99
+ ```typescript
100
+ return telemetryContext.startSpan({ name: 'example.parent' }, async (parentSpan) => {
101
+ return parentSpan.startSpan({ name: 'example.child' }, async (childSpan) => {
102
+ childSpan.addEvent('example.cache.lookup', { 'example.cache.hit': true });
103
+ return performWork();
104
+ });
105
+ });
106
+ ```
107
+
108
+ There is no public `end()` method. `startSpan()` owns settlement and keeps the span open until the callback's value or promise settles. For an expected failure represented by a normal return value, set the status explicitly:
109
+
110
+ ```typescript
111
+ return telemetryContext.startSpan({ name: 'example.save' }, async (span) => {
112
+ const result = await save();
113
+ if (!result.ok) {
114
+ span.setStatus({
115
+ status: 'error',
116
+ error: { name: 'SaveError', message: result.reason },
117
+ });
118
+ }
119
+ return result;
120
+ });
121
+ ```
122
+
123
+ ## Adapter Contract
124
+
125
+ An adapter implements `TelemetryContext` and bridges the generic API to its backend. Typed inputs are trusted; malformed inputs have unspecified behavior. Adapters are not required to validate inputs or suppress adapter/backend failures.
126
+
127
+ The callback and recording contract is:
128
+
129
+ - create a child span and invoke the callback synchronously, exactly once;
130
+ - expose optional metric and propagation capabilities on its contexts, child spans, and extracted parents, including inert fallback spans;
131
+ - preserve the callback's returned value and rejection value, returning a promise rejected with the same value after a synchronous throw;
132
+ - keep the native span open until a returned promise settles;
133
+ - treat normal completion as `ok` and throws/rejections as errors unless an explicit status was set;
134
+ - make repeated `setStatus()` calls last-write-wins;
135
+ - merge `setAttributes()` calls, with later defined values replacing earlier values and `undefined` ignored;
136
+ - make recording methods synchronous;
137
+ - ignore calls made after settlement.
138
+
139
+ Adapters may activate backend-native ambient context internally for automatic instrumentation, but pi code always propagates the parent through `TelemetryContext` arguments. Exporter buffering, flushing, sampling, backend IDs, and backend-specific context objects belong to the adapter. Use the [adapter conformance suite](#adapter-conformance) to check these observable semantics.
140
+
141
+ ## Propagation and Span Links
142
+
143
+ An adapter may also implement `TelemetryPropagation`. Expose it through `context.propagation` so code can inject or extract using the context it already has. The root, spans, and extracted parents share the adapter's capability object:
144
+
145
+ ```typescript
146
+ interface TelemetryPropagation {
147
+ inject(context: TelemetryContext): TelemetryCarrier | undefined;
148
+ extract(carrier: TelemetryCarrier | undefined): TelemetryContext;
149
+ }
150
+ type TelemetryCarrier = Record<string, string>;
151
+ ```
152
+
153
+ A carrier is an opaque JSON-safe string map. Adapters own its format; W3C `traceparent` and `tracestate` fit without requiring them. Transport code sends only the carrier, never a context or span:
154
+
155
+ ```typescript
156
+ const caller = new InMemoryTelemetryContext();
157
+ const provider = new InMemoryTelemetryContext();
158
+ await caller.startSpan({ name: 'rpc.client' }, async (span) => {
159
+ const wire = JSON.stringify(caller.inject(span));
160
+ await provider.extract(JSON.parse(wire)).startSpan(
161
+ { name: 'rpc.server' },
162
+ (serverSpan) => handleRequest(serverSpan),
163
+ );
164
+ });
165
+ ```
166
+
167
+ Injection returns a detached carrier or `undefined` for roots, no-op contexts, and unsupported contexts. Extraction returns a receiver-owned parent. Absent or unknown carriers fall back to the adapter's root. Extraction checks only that required carrier keys contain strings; identifiers are opaque and need no format validation. Both methods are synchronous. Trace metadata is diagnostics, never authorization. Transport boundaries own carrier size limits.
168
+
169
+ A link relates work without changing its parent. Use `SpanOptions.links` when the target is known at creation, or `span.addLink()` after the span has started:
170
+
171
+ ```typescript
172
+ await telemetry.startSpan(
173
+ { name: 'activation', links: [{ context: firstGetter, attributes: { reason: 'trigger' } }] },
174
+ async (activation) => {
175
+ laterGetter.addLink({ context: activation });
176
+ await activate();
177
+ },
178
+ );
179
+ ```
180
+
181
+ Link targets are `TelemetryContext` handles, including spans and extracted remote parents. For a remote link, first call `adapter.extract(carrier)`. A settled target remains linkable and injectable; a settled source ignores `addLink()`. Links append in order, copy attributes, and ignore root, no-op, or unsupported contexts. The typed span starter's callback also supports `addLink()`; initial links use the low-level `startSpan()` API.
182
+
183
+ `NOOP_TELEMETRY_PROPAGATION` ignores its arguments: injection returns `undefined`, extraction returns `NOOP_TELEMETRY_CONTEXT`.
184
+
185
+ ## Metrics
186
+
187
+ An adapter may implement `TelemetryMetrics` alongside `TelemetryContext` and `TelemetryPropagation`. Applications configure one adapter, not separate backends for each capability. Expose the shared capability through `context.metrics`; child spans retain it, so nested model calls can record metrics without a separate configuration option. The in-memory adapter also implements the factory methods directly:
188
+
189
+ ```typescript
190
+ const bytes = telemetry.counter('network.bytes', {
191
+ description: 'Encoded WebSocket bytes', unit: 'By',
192
+ });
193
+ bytes.add(encodedSize, { direction: 'out' });
194
+
195
+ const active = telemetry.upDownCounter('calls.active');
196
+ active.add(1);
197
+ active.add(-1);
198
+
199
+ const duration = telemetry.histogram('call.duration', {
200
+ unit: 'ms', boundaries: [1, 10, 100, 1000],
201
+ });
202
+ duration.record(elapsedMs, { method: 'read' }, currentSpan);
203
+ ```
204
+
205
+ Factories take an instrument name and optional `InstrumentOptions` (`description`, `unit`). Histograms additionally accept `HistogramOptions.boundaries`: callers supply finite, strictly increasing bucket hints. Backends own aggregation and may translate these hints. Callers are responsible for finite measurements and non-negative counter increments; up-down counters and histograms may use signed values. Histogram `record(value, attributes?, context?)` optionally correlates a measurement with a span, called an exemplar. Counter `add(value, attributes?)` has no context argument.
206
+
207
+ Factories and recording methods are synchronous and trust typed inputs. Adapters need not validate measurement values, metadata, attributes, or boundaries; malformed inputs have unspecified behavior. `NOOP_TELEMETRY_METRICS` returns shared frozen instruments that discard measurements. Record only values already available to business code; do not serialize or measure extra data for telemetry.
208
+
209
+ Use only low-cardinality attributes: bounded values such as direction or method, not request IDs, user IDs, prompts, or arbitrary paths. The low-level attribute bag remains open; instrumentation owns this policy, not backend-specific heuristics.
210
+
211
+ ### Typed metric schemas
212
+
213
+ `TelemetrySchemaDefinition.metrics` optionally declares instrument names, kinds, metadata, and required/optional attributes. `createTypedMetrics()` combines one or more schemas and rejects duplicate metric names, incorrect instrument kinds, unknown attributes, and missing required attributes at compile time:
214
+
215
+ ```typescript
216
+ const schema = defineTelemetrySchema({
217
+ version: 1,
218
+ spans: {},
219
+ metrics: {
220
+ 'network.bytes': {
221
+ kind: 'counter', description: 'Encoded bytes', unit: 'By',
222
+ attributes: {
223
+ direction: {
224
+ type: 'string', required: true, values: ['in', 'out'],
225
+ description: 'Direction', cardinality: 'low',
226
+ },
227
+ },
228
+ },
229
+ },
230
+ } as const);
231
+
232
+ const metrics = createTypedMetrics(telemetry, [schema]);
233
+ metrics.counter('network.bytes').add(encodedSize, { direction: 'out' });
234
+ ```
235
+
236
+ Metric definitions use `kind: 'counter' | 'upDownCounter' | 'histogram'`; only histograms may declare `boundaries`. Metric attributes use the same scalar/array definitions as spans, but reject `cardinality: 'high'`. Omitting the cardinality annotation does not waive the low-cardinality policy. Unlike the typed span starter, the metric helper reads and retains schema instrument metadata at runtime. It performs no runtime attribute validation.
237
+
238
+ `InMemoryTelemetryContext.getMetrics()` returns ordered, detached `RecordedTelemetryMeasurement` snapshots: `kind`, `name`, optional `description`/`unit`/`boundaries`, `value`, copied `attributes`, and a carrier-shaped exemplar `context` or `null`. This is a measurement log, not aggregated counters or buckets; storage is unbounded. Measurement times are retained privately for store queries, not exposed in these snapshots.
239
+
240
+ ## No-op Context
241
+
242
+ Use `NOOP_TELEMETRY_CONTEXT` when telemetry is optional:
243
+
244
+ ```typescript
245
+ import { NOOP_TELEMETRY_CONTEXT } from '@quicksortsas/telemetry';
246
+
247
+ const result = await NOOP_TELEMETRY_CONTEXT.startSpan(
248
+ { name: 'example.operation' },
249
+ () => runOperation(),
250
+ );
251
+ ```
252
+
253
+ The no-op context:
254
+
255
+ - invokes callbacks synchronously;
256
+ - preserves returned values and asynchronous rejections, and converts a synchronous throw to a promise rejected with the same value;
257
+ - uses one shared frozen inert span, including for nested spans;
258
+ - exposes `NOOP_TELEMETRY_METRICS` and `NOOP_TELEMETRY_PROPAGATION` on its root and spans;
259
+ - does not inspect or retain names, attributes, events, or statuses.
260
+
261
+ ## In-Memory Reference Adapter
262
+
263
+ `InMemoryTelemetryContext` is the backend-neutral reference implementation. It is useful for tests, local diagnostics, and applications that intentionally want process-local capture without an exporter:
264
+
265
+ ```typescript
266
+ import { InMemoryTelemetryContext } from '@quicksortsas/telemetry';
267
+
268
+ const telemetry = new InMemoryTelemetryContext();
269
+
270
+ await telemetry.startSpan(
271
+ { name: 'example.operation', attributes: { input: 'demo' } },
272
+ async (span) => {
273
+ span.addEvent('example.started');
274
+ span.setAttributes({ output_count: 3 });
275
+ },
276
+ );
277
+
278
+ console.log(telemetry.getSpans());
279
+ ```
280
+
281
+ `getSpans()` returns detached snapshots in span-start order. Each `RecordedTelemetrySpan` contains a deterministic numeric ID, local parent ID, carrier-shaped `context` and `parentContext` snapshots, ordered `links`, merged attributes, ordered events, final status, settlement state, and deterministic end sequence. These normalized snapshots expose no timestamps. A remote parent has `parentId: null` but a non-null `parentContext`; a root has both null. A `RecordedTelemetryLink` contains a target carrier and copied attributes.
282
+
283
+ The adapter implements `TelemetryPropagation` with its own small carrier codec: a unique recorder scope, a deterministic numeric span ID, and an inherited trace ID. The carrier survives JSON round trips and works across independent processes without a shared registry. Its keys are an implementation detail, not a public wire format for other adapters.
284
+
285
+ The adapter is safe to use as an ordinary `TelemetryContext`, but storage is unbounded and process-local. Create a fresh instance to isolate tests or recording scopes, and do not capture sensitive attributes unless the caller's data policy allows them.
286
+
287
+ ## Stored Telemetry
288
+
289
+ `TelemetryStore` separates querying from recording. Both `InMemoryTelemetryContext` and `@quicksortsas/chord/telemetry-sqlite` implement it:
290
+
291
+ - `findTraces(query?)` returns complete locally stored traces, newest trace start first. Filters include the earliest stored span's `name`, inclusive `startMin`/`startMax`, `errors: true`, and scalar `attributes`. All requested attributes must match one span, anywhere in the trace. Pages default to 20 traces; pass the returned opaque `cursor` with the same filters to continue. Equal start times are ordered by trace ID.
292
+ - `getTrace(traceId)` returns a trace or `undefined`.
293
+ - `findMetrics({ name, startMin?, startMax?, attributes? })` returns metric rows with count, sum, minimum, and maximum. Time bounds are inclusive interval-start times. The in-memory adapter maps each raw measurement to a count-one row in its ten-second interval; SQLite aggregates measurements per series and interval.
294
+
295
+ Only settled spans are returned. A trace ID is the root's globally unique span ID (`<recorder UUID>:<counter>`) and propagates with the parent identity. Stores return the spans available locally: remote or expired parents can be absent, and a child can appear before its parent settles. Pagination is not a snapshot across requests; late settlements and retention can change a trace's earliest stored span. SQLite reads committed rows without implicitly flushing.
296
+
297
+ `StoredSpan` includes epoch-millisecond `startedMs`/`endedMs`, event offsets in `timeMs`, and links carrying both target `traceId` and `spanId`. Duration uses a monotonic clock; wall clocks across processes may disagree. Merge spans from several stores by trace ID; parent relationships, not timestamp containment, define the tree.
298
+
299
+ Rendering is a separate subpath, kept out of the root entry graph:
300
+
301
+ ```typescript
302
+ import { renderTrace } from '@quicksortsas/telemetry/render';
303
+
304
+ const page = await telemetry.findTraces({ errors: true, limit: 10 });
305
+ for (const trace of page.traces) console.log(renderTrace(trace.spans));
306
+ ```
307
+
308
+ `renderTrace()` accepts stored spans, deduplicates IDs, treats absent parents as roots, and prints attributes, durations, statuses, event offsets, and cross-trace links. It never follows links or queries a store.
309
+
310
+ ## Adapter Conformance
311
+
312
+ `@quicksortsas/telemetry/testing` exports a runner-independent conformance suite modeled as grouped cases. A fixture supplies a fresh context and converts its backend's finished spans into normalized `RecordedTelemetrySpan` snapshots:
313
+
314
+ ```typescript
315
+ import {
316
+ createTelemetryAdapterConformance,
317
+ type TelemetryAdapterFixture,
318
+ } from '@quicksortsas/telemetry/testing';
319
+ import { describe, it } from 'vitest';
320
+
321
+ const conformance = createTelemetryAdapterConformance(async () => {
322
+ const adapter = createMyTelemetryAdapter();
323
+ return {
324
+ context: adapter.context,
325
+ getSpans: async () => adapter.normalizedSpans(),
326
+ async [Symbol.asyncDispose]() {
327
+ await adapter.close();
328
+ },
329
+ } satisfies TelemetryAdapterFixture;
330
+ });
331
+
332
+ for (const group of new Set(conformance.map((testCase) => testCase.group))) {
333
+ describe(group, () => {
334
+ for (const testCase of conformance.filter((candidate) => candidate.group === group)) {
335
+ it(testCase.name, () => testCase.run());
336
+ }
337
+ });
338
+ }
339
+ ```
340
+
341
+ The suite checks capability identity on roots, child spans, extracted parents and inert spans, synchronous single admission, result and rejection identity, automatic and explicit status, attribute merging, event ordering, inert post-settlement calls, nested and concurrent parentage, and links. Fixtures may supply `propagation` to additionally check JSON round trips, multiple adapter hops, and root fallback for absent or unknown carriers. Fixtures may also supply `metrics` and `getMetrics()` to check ordered measurements, histogram exemplars, and input copying. It does not test malformed inputs or require suppression of adapter/backend failures. `getSpans()` may flush an asynchronous exporter before returning. The testing subpath uses Node's assertion APIs; the root telemetry package remains runtime-neutral.
342
+
343
+ ## Typed Schemas
344
+
345
+ The low-level span API intentionally accepts open names and attribute bags so adapters remain generic. Domain packages can define closed, serializable schemas and infer exact TypeScript types from them.
346
+
347
+ ```typescript
348
+ import {
349
+ createTypedSpanStarter,
350
+ defineTelemetrySchema,
351
+ } from '@quicksortsas/telemetry';
352
+
353
+ export const EXAMPLE_TELEMETRY_SCHEMA = defineTelemetrySchema({
354
+ version: 1,
355
+ spans: {
356
+ 'example.read': {
357
+ description: 'Read one resource',
358
+ parents: { kind: 'any' },
359
+ startAttributes: {
360
+ 'example.resource': {
361
+ type: 'string',
362
+ required: true,
363
+ values: ['account', 'project'],
364
+ description: 'Resource kind',
365
+ },
366
+ },
367
+ endAttributes: {
368
+ 'example.item_count': {
369
+ type: 'number',
370
+ description: 'Number of returned items',
371
+ },
372
+ },
373
+ events: {
374
+ 'example.cache': {
375
+ description: 'Cache lookup result',
376
+ attributes: {
377
+ 'example.cache.hit': {
378
+ type: 'boolean',
379
+ required: true,
380
+ description: 'Whether the cache contained the resource',
381
+ },
382
+ },
383
+ },
384
+ },
385
+ status: {
386
+ default: 'ok',
387
+ errorWhen: 'The read throws or returns an error result',
388
+ },
389
+ },
390
+ },
391
+ } as const);
392
+
393
+ const startSpan = createTypedSpanStarter(
394
+ telemetryContext,
395
+ [EXAMPLE_TELEMETRY_SCHEMA],
396
+ );
397
+ ```
398
+
399
+ The starter exposes one overload per span and checks names and attributes at compile time. Union-valued names must be narrowed before a call, preserving the relationship between each runtime name and its attribute schema. Its callback receives a child starter over the same schemas, already bound to the callback span:
400
+
401
+ ```typescript
402
+ await startSpan(
403
+ 'example.read',
404
+ { 'example.resource': 'account' },
405
+ async (span, startChildSpan) => {
406
+ span.addEvent('example.cache', { 'example.cache.hit': true });
407
+ const accounts = await readAccounts();
408
+ span.setAttributes({ 'example.item_count': accounts.length });
409
+
410
+ await startChildSpan(
411
+ 'example.read',
412
+ { 'example.resource': 'project' },
413
+ async (childSpan) => {
414
+ const projects = await readProjects();
415
+ childSpan.setAttributes({ 'example.item_count': projects.length });
416
+ },
417
+ );
418
+
419
+ return accounts;
420
+ },
421
+ );
422
+ ```
423
+
424
+ ### Start and Completion Attributes
425
+
426
+ `startAttributes` and `endAttributes` describe when an attribute is normally known, not separate runtime storage:
427
+
428
+ | Schema field | How values are recorded | Requiredness |
429
+ |---|---|---|
430
+ | `startAttributes` | Passed in the typed starter's `attributes` argument when the span is created | Each definition explicitly sets `required: true` or `false` |
431
+ | `endAttributes` | Added later through the schema-scoped span's `setAttributes()` method | Always optional |
432
+
433
+ Both sets become ordinary attributes on the same backend span. There is no separate end-attribute payload or end callback. In the preceding example, `example.resource` is known when `example.read` starts, while `example.item_count` is known only after `readAccounts()` returns:
434
+
435
+ ```typescript
436
+ await startSpan(
437
+ 'example.read',
438
+ { 'example.resource': 'account' }, // required start attribute
439
+ async (span) => {
440
+ const accounts = await readAccounts();
441
+ span.setAttributes({
442
+ 'example.item_count': accounts.length, // optional completion attribute
443
+ });
444
+ return accounts;
445
+ },
446
+ ); // resolving the callback settles the span
447
+ ```
448
+
449
+ “End” means completion enrichment: an end attribute may be set at any point while the callback is active, and it may be omitted when unavailable. Calling `setAttributes()` zero times is valid. This matters for early failures, cancellation, and provider-specific data that may not exist on every path.
450
+
451
+ Repeated `setAttributes()` calls merge into the same attribute bag. A later defined value replaces an earlier value for the same key, while `undefined` is ignored. The schema-scoped method accepts only the current span's declared end attributes.
452
+
453
+ Attributes do not end the span. Returning, resolving, throwing, or rejecting from the callback controls settlement; `startSpan()` performs the actual end operation. Adapter calls made after settlement are inert.
454
+
455
+ A starter can compose multiple independently versioned schemas:
456
+
457
+ ```typescript
458
+ import { AGENT_TELEMETRY_SCHEMAS } from '@quicksortsas/agent-core';
459
+
460
+ const startAgentSpan = createTypedSpanStarter(
461
+ telemetryContext,
462
+ AGENT_TELEMETRY_SCHEMAS,
463
+ );
464
+ ```
465
+
466
+ Inline schema arrays retain their tuple types automatically. Separately declared arrays should use `as const`. Literal duplicate span names across the array are rejected at compile time; schemas are not merged, inspected, or retained at runtime.
467
+
468
+ Schema-derived types reject missing required attributes, unknown keys, invalid closed-set values, undeclared events, and attributes on empty schemas. End attributes are always optional enrichment; the type system does not require `setAttributes()` to be called.
469
+
470
+ `defineTelemetrySchema()` is a typed identity function. It returns ordinary JSON-serializable data and performs no runtime validation or parent-rule enforcement.
471
+
472
+ ## Schema Metadata
473
+
474
+ Supported attribute types are:
475
+
476
+ - `string`, `number`, and `boolean`;
477
+ - `string[]`, `number[]`, and `boolean[]`.
478
+
479
+ Attribute definitions support:
480
+
481
+ - `values`: a closed set for scalar values;
482
+ - `elementValues`: a closed set for array elements;
483
+ - `examples`: documentation examples;
484
+ - `sensitive`: marks data requiring special handling;
485
+ - `cardinality`: records expected `low` or `high` cardinality.
486
+
487
+ Start and event attributes declare `required`. End attributes do not; see [Start and Completion Attributes](#start-and-completion-attributes).
488
+
489
+ Parent metadata is descriptive schema data:
490
+
491
+ - `{ kind: 'any' }`: root or any caller span;
492
+ - `{ kind: 'root_or_external' }`: root or a caller-owned span outside the schema;
493
+ - `{ kind: 'spans', spans: [...] }`: only the listed schema spans.
494
+
495
+ Adapters do not need to understand schema objects. Instrumentation helpers and tests use them to keep emitted names and attributes consistent.
496
+
497
+ ## QFactory Package Integration
498
+
499
+ Package ownership is intentionally split:
500
+
501
+ - `@quicksortsas/telemetry` owns the vendor-neutral contract, no-op and in-memory reference contexts, schema utilities, and adapter conformance suite;
502
+ - `@quicksortsas/ai` accepts a parent `TelemetrySpan` through `telemetry` in provider request options and exports `AI_TELEMETRY_SCHEMA` from its root and `./models` entry points;
503
+ - `@quicksortsas/durable` exports `DURABLE_TELEMETRY_SCHEMA` for short task-handler and submission spans, events, and metrics;
504
+ - `@quicksortsas/agent-core` retains its existing agent-loop and session instrumentation and schemas.
505
+
506
+ ```typescript
507
+ import { AI_TELEMETRY_SCHEMA } from '@quicksortsas/ai/models';
508
+ import { DURABLE_TELEMETRY_SCHEMA } from '@quicksortsas/durable';
509
+ ```
510
+
511
+ The AI and Durable schemas use pi-owned `pi.ai.*` and `qfactory.durable.*` names. Adapters may translate them to backend conventions without changing the emitted pi vocabulary.
512
+
513
+ ## Security and Portability
514
+
515
+ Telemetry is diagnostics, not durable application state. Do not persist a `TelemetryContext`, `TelemetrySpan`, or backend-native trace object in records, messages, snapshots, or deferred handles. An injected `TelemetryCarrier` may cross a transport or be stored as optional diagnostic correlation data. Changing adapters may lose correlation; carriers are not application state.
516
+
517
+ Attribute values are intentionally limited to primitive scalars and arrays. Domain instrumentation should avoid prompts, completions, tool arguments or output, file contents, provider payloads, headers, credentials, and free-form error details unless its schema and data policy explicitly allow them.
518
+
519
+ The package does not use `AsyncLocalStorage` or another runtime-specific ambient context API. It is suitable for Node.js, Bun, browsers, and workers; backend adapters remain responsible for their own runtime compatibility.
520
+
521
+ ## API Reference
522
+
523
+ ### Core types and values
524
+
525
+ | Export | Purpose |
526
+ |---|---|
527
+ | `TelemetryContext` | Starts callback-managed child spans and optionally exposes adapter metrics/propagation |
528
+ | `TelemetrySpan` | Records attributes, events, and status; also acts as a child context |
529
+ | `SpanOptions` | Span name, optional start attributes and links |
530
+ | `SpanLink` | Context target and optional link attributes |
531
+ | `TelemetryCarrier` | Opaque JSON-safe string map |
532
+ | `TelemetryPropagation` | Optional adapter injection and extraction capability |
533
+ | `NOOP_TELEMETRY_PROPAGATION` | Shared passive propagation for disabled telemetry |
534
+ | `TelemetryMetrics` | Optional adapter instrument factories |
535
+ | `TelemetryCounter` / `TelemetryUpDownCounter` | Instruments with `add()` |
536
+ | `TelemetryHistogram` | Instrument with `record()` and optional exemplar context |
537
+ | `InstrumentOptions` / `HistogramOptions` | Description, unit, and histogram boundary hints |
538
+ | `NOOP_TELEMETRY_METRICS` | Shared passive metric instruments |
539
+ | `RecordedTelemetryMeasurement` | Captured measurement and optional exemplar |
540
+ | `SpanAttributes` / `AttributeValue` | Open adapter-level attribute bag and supported values |
541
+ | `SpanStatus` | Explicit `ok` or `error` status |
542
+ | `NOOP_TELEMETRY_CONTEXT` | Shared passive context for disabled telemetry |
543
+ | `InMemoryTelemetryContext` | Reference adapter with deterministic process-local recording |
544
+ | `RecordedTelemetrySpan` | Normalized captured span snapshot |
545
+ | `RecordedTelemetryEvent` | Normalized captured event snapshot |
546
+ | `RecordedTelemetryLink` | Captured target carrier and link attributes |
547
+ | `TelemetryStore` | Settled trace discovery, trace retrieval, and metric queries |
548
+ | `StoredSpan` / `StoredTrace` | Backend-independent timed span and trace snapshots |
549
+ | `TraceQuery` / `MetricQuery` | Trace filters, pagination, and metric filters |
550
+ | `StoredMetric` | Metric interval row with count, sum, minimum, and maximum |
551
+
552
+ ### Schema definitions and inference
553
+
554
+ | Export | Purpose |
555
+ |---|---|
556
+ | `defineTelemetrySchema()` | Typed identity helper for serializable schema data |
557
+ | `createTypedSpanStarter()` | Binds a parent context to one or more schema vocabularies |
558
+ | `createTypedMetrics()` / `TypedTelemetryMetrics` | Binds metric names, kinds, metadata, and attributes to schemas |
559
+ | `TelemetryMetricDefinition` / `TelemetryMetricAttributeDefinition` | Serializable instrument metadata and low-cardinality attribute definitions |
560
+ | `SchemaTelemetryCounter` / `SchemaTelemetryHistogram` | Schema-constrained recording methods |
561
+ | `TypedSpanStarter` | Exact starter type with recursively child-bound callbacks |
562
+ | `TelemetrySchemaDefinition` | Top-level schema shape |
563
+ | `TelemetrySpanDefinition` | Span metadata, parents, attributes, events, and status rule |
564
+ | `TelemetryAttributeType` | Supported scalar and array type names |
565
+ | `TelemetryAttributeMetadata` | Description, sensitivity, and cardinality metadata |
566
+ | `TelemetryAttributeDefinition` | Attribute type, allowed values, examples, and metadata |
567
+ | `TelemetryStartAttributeDefinition` | Start attribute definition with requiredness |
568
+ | `TelemetryEventAttributeDefinition` | Event attribute definition with requiredness |
569
+ | `TelemetryEventDefinition` | Event description and attribute definitions |
570
+ | `TelemetryParentDefinition` | Open, external-root, or finite schema-parent rule |
571
+ | `TelemetrySchemaSpanName` | Union of declared span names |
572
+ | `TelemetrySchemaSpanStartAttributes` | Exact inferred start attributes for one span |
573
+ | `TelemetrySchemaSpanEndAttributes` | Optional inferred end attributes for one span |
574
+ | `TelemetrySchemaSpanEventName` | Union of events declared by one span |
575
+ | `TelemetrySchemaSpanEventAttributes` | Exact inferred attributes for one event |
576
+ | `SchemaTelemetrySpan` | Span view restricted to one schema span |
577
+ | `TelemetrySchemaSpanUnion` | Discriminated union of all spans in a schema |
578
+ | `InferStartAttributes` | Required and optional values inferred from start definitions |
579
+ | `InferOptionalAttributes` | Optional values inferred from end definitions |
580
+ | `InferEventAttributes` | Required and optional values inferred from event definitions |
581
+ | `InferRequiredAndOptionalAttributes` | Shared inference utility for definitions with requiredness |
582
+ | `ExactTelemetryAttributes` | Rejects keys outside an expected attribute set |
583
+
584
+ ### Testing subpath
585
+
586
+ | Export | Purpose |
587
+ |---|---|
588
+ | `createTelemetryAdapterConformance()` | Creates runner-independent adapter conformance cases |
589
+ | `TelemetryAdapterFixture` | Fresh context and normalized snapshot reader for one case |
590
+ | `TelemetryAdapterFixtureFactory` | Creates isolated fixtures |
591
+ | `TelemetryAdapterConformanceCase` | Grouped case that test runners execute |
592
+
593
+ ## Development
594
+
595
+ From this package directory:
596
+
597
+ ```bash
598
+ npm test
599
+ npm run build
600
+ ```
601
+
602
+ Repository-wide type checking, formatting, linting, and smoke checks run with:
603
+
604
+ ```bash
605
+ npm run check
606
+ ```
607
+
608
+ ## License
609
+
610
+ MIT