@graphorin/observability 0.6.0 → 0.7.0

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.
Files changed (86) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +29 -5
  3. package/dist/cost/cost-tracker.d.ts.map +1 -1
  4. package/dist/cost/cost-tracker.js +34 -0
  5. package/dist/cost/cost-tracker.js.map +1 -1
  6. package/dist/cost/delegate.d.ts +40 -0
  7. package/dist/cost/delegate.d.ts.map +1 -0
  8. package/dist/cost/delegate.js +31 -0
  9. package/dist/cost/delegate.js.map +1 -0
  10. package/dist/cost/index.d.ts +2 -1
  11. package/dist/cost/index.js +2 -1
  12. package/dist/cost/types.d.ts +39 -0
  13. package/dist/cost/types.d.ts.map +1 -1
  14. package/dist/exporters/otlp-http.d.ts.map +1 -1
  15. package/dist/exporters/otlp-http.js +4 -2
  16. package/dist/exporters/otlp-http.js.map +1 -1
  17. package/dist/exporters/types.d.ts +7 -0
  18. package/dist/exporters/types.d.ts.map +1 -1
  19. package/dist/exporters/types.js.map +1 -1
  20. package/dist/exporters/with-validation.d.ts.map +1 -1
  21. package/dist/exporters/with-validation.js +5 -4
  22. package/dist/exporters/with-validation.js.map +1 -1
  23. package/dist/gen-ai/emit.js +9 -1
  24. package/dist/gen-ai/emit.js.map +1 -1
  25. package/dist/index.d.ts +3 -3
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +5 -10
  28. package/dist/index.js.map +1 -1
  29. package/dist/package.js +6 -0
  30. package/dist/package.js.map +1 -0
  31. package/dist/redaction/imperative-patterns.d.ts +3 -3
  32. package/dist/redaction/imperative-patterns.d.ts.map +1 -1
  33. package/dist/redaction/imperative-patterns.js +7 -0
  34. package/dist/redaction/imperative-patterns.js.map +1 -1
  35. package/dist/redaction/patterns.d.ts.map +1 -1
  36. package/dist/redaction/patterns.js +2 -2
  37. package/dist/redaction/patterns.js.map +1 -1
  38. package/dist/tracer/sampling.d.ts +6 -1
  39. package/dist/tracer/sampling.d.ts.map +1 -1
  40. package/dist/tracer/sampling.js +7 -1
  41. package/dist/tracer/sampling.js.map +1 -1
  42. package/dist/tracer/span.d.ts.map +1 -1
  43. package/dist/tracer/span.js +12 -3
  44. package/dist/tracer/span.js.map +1 -1
  45. package/package.json +18 -35
  46. package/src/cost/cost-tracker.ts +315 -0
  47. package/src/cost/delegate.ts +79 -0
  48. package/src/cost/index.ts +23 -0
  49. package/src/cost/types.ts +151 -0
  50. package/src/eval/index.ts +16 -0
  51. package/src/eval/runner.ts +146 -0
  52. package/src/eval/types.ts +122 -0
  53. package/src/exporters/console.ts +75 -0
  54. package/src/exporters/index.ts +36 -0
  55. package/src/exporters/jsonl.ts +167 -0
  56. package/src/exporters/otlp-http.ts +178 -0
  57. package/src/exporters/types.ts +85 -0
  58. package/src/exporters/with-validation.ts +176 -0
  59. package/src/gen-ai/emit.ts +157 -0
  60. package/src/gen-ai/index.ts +26 -0
  61. package/src/gen-ai/operation-mapping.ts +89 -0
  62. package/src/gen-ai/system-derivation.ts +84 -0
  63. package/src/gen-ai/types.ts +143 -0
  64. package/src/index.ts +36 -0
  65. package/src/logger/index.ts +13 -0
  66. package/src/logger/logger.ts +178 -0
  67. package/src/openinference/index.ts +133 -0
  68. package/src/redaction/config.ts +50 -0
  69. package/src/redaction/errors.ts +53 -0
  70. package/src/redaction/imperative-patterns.ts +268 -0
  71. package/src/redaction/index.ts +42 -0
  72. package/src/redaction/patterns.ts +263 -0
  73. package/src/redaction/types.ts +116 -0
  74. package/src/redaction/validator.ts +302 -0
  75. package/src/replay/config.ts +58 -0
  76. package/src/replay/index.ts +17 -0
  77. package/src/replay/log.ts +89 -0
  78. package/src/replay/replay.ts +196 -0
  79. package/src/replay/types.ts +112 -0
  80. package/src/telemetry/index.ts +94 -0
  81. package/src/tracer/ids.ts +27 -0
  82. package/src/tracer/index.ts +22 -0
  83. package/src/tracer/sampling.ts +170 -0
  84. package/src/tracer/span-names.ts +52 -0
  85. package/src/tracer/span.ts +175 -0
  86. package/src/tracer/tracer.ts +309 -0
@@ -0,0 +1,178 @@
1
+ import pkg from '../../package.json' with { type: 'json' };
2
+ /**
3
+ * `OTLPHttpExporter` - reference implementation that POSTs span
4
+ * records to an OpenTelemetry HTTP endpoint.
5
+ *
6
+ * The exporter is deliberately minimal: production hardening (retry
7
+ * logic, batching policy tuning, gzip compression, bearer-token auth
8
+ * rotation) is left to operators. The wire format follows the OTLP
9
+ * `traces` resource shape and is compatible with collectors that accept
10
+ * the `application/json` content type.
11
+ *
12
+ * Operators wanting the upstream OpenTelemetry SDK exporter
13
+ * (`@opentelemetry/exporter-trace-otlp-http`) can adapt
14
+ * {@link toOtlpEnvelope} into their own wrapper - the helper is
15
+ * exported so the OTel adapter does not need to duplicate the wire
16
+ * shape.
17
+ *
18
+ * @packageDocumentation
19
+ */
20
+
21
+ import type { SpanAttributes, SpanAttributeValue, SpanType } from '@graphorin/core';
22
+
23
+ import type { SpanRecord, SpanRecordEvent, TraceExporter } from './types.js';
24
+
25
+ /**
26
+ * Configuration shape for {@link createOTLPHttpExporter}.
27
+ *
28
+ * @stable
29
+ */
30
+ export interface OTLPHttpExporterOptions {
31
+ /** Identifier reported via `exporter.id`. Defaults to `'otlp-http'`. */
32
+ readonly id?: string;
33
+ /** OTLP collector URL (e.g. `http://localhost:4318/v1/traces`). */
34
+ readonly url: string;
35
+ /** Optional headers (auth, tenant id, …). Avoid passing secrets in plain. */
36
+ readonly headers?: Readonly<Record<string, string>>;
37
+ /** Service name embedded in the OTLP `Resource`. */
38
+ readonly serviceName?: string;
39
+ /** Optional `fetch` override - useful for testing without the network. */
40
+ readonly fetchImpl?: typeof fetch;
41
+ /** Optional timeout (ms). Defaults to 10000. */
42
+ readonly timeoutMs?: number;
43
+ }
44
+
45
+ /**
46
+ * Build a minimal OTLP-over-HTTP trace exporter. Call
47
+ * `withValidation(exporter)` before passing the result to
48
+ * `createTracer({ exporters })`.
49
+ *
50
+ * The implementation issues one `POST` per finished span - no
51
+ * batching. Operators wanting batching should wrap this exporter
52
+ * with their own queue or use a sidecar collector.
53
+ *
54
+ * @stable
55
+ */
56
+ export function createOTLPHttpExporter(opts: OTLPHttpExporterOptions): TraceExporter {
57
+ const id = opts.id ?? 'otlp-http';
58
+ const url = opts.url;
59
+ const headers = opts.headers ?? {};
60
+ const serviceName = opts.serviceName ?? 'graphorin';
61
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
62
+ const timeoutMs = opts.timeoutMs ?? 10_000;
63
+ let closed = false;
64
+
65
+ if (typeof fetchImpl !== 'function') {
66
+ throw new TypeError(
67
+ 'createOTLPHttpExporter: no fetch implementation available. Provide ' +
68
+ '`fetchImpl` explicitly or run on a Node.js version with global fetch ' +
69
+ '(>= 18).',
70
+ );
71
+ }
72
+
73
+ return {
74
+ id,
75
+ async export(record: SpanRecord): Promise<void> {
76
+ if (closed) return;
77
+ const body = toOtlpEnvelope(record, serviceName);
78
+ const controller = new AbortController();
79
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
80
+ try {
81
+ const res = await fetchImpl(url, {
82
+ method: 'POST',
83
+ headers: { 'content-type': 'application/json', ...headers },
84
+ body: JSON.stringify(body),
85
+ signal: controller.signal,
86
+ });
87
+ if (!res.ok) {
88
+ throw new Error(`OTLP exporter received HTTP ${res.status} ${res.statusText}`);
89
+ }
90
+ } finally {
91
+ clearTimeout(timer);
92
+ }
93
+ },
94
+ async flush(): Promise<void> {
95
+ // No internal queue; each export() awaits the POST already.
96
+ },
97
+ async shutdown(): Promise<void> {
98
+ closed = true;
99
+ },
100
+ };
101
+ }
102
+
103
+ /**
104
+ * @internal - exposed for unit tests
105
+ */
106
+ export function toOtlpEnvelope(record: SpanRecord, serviceName: string): unknown {
107
+ const otelStatus = record.status === 'ok' ? 1 : record.status === 'error' ? 2 : 0;
108
+ return {
109
+ resourceSpans: [
110
+ {
111
+ resource: {
112
+ attributes: [
113
+ { key: 'service.name', value: { stringValue: serviceName } },
114
+ { key: 'graphorin.framework.version', value: { stringValue: pkg.version } },
115
+ ],
116
+ },
117
+ scopeSpans: [
118
+ {
119
+ scope: { name: '@graphorin/observability', version: pkg.version },
120
+ spans: [
121
+ {
122
+ traceId: record.traceId,
123
+ spanId: record.id,
124
+ ...(record.parentId === undefined ? {} : { parentSpanId: record.parentId }),
125
+ name: record.name,
126
+ kind: spanKindFor(record.type),
127
+ startTimeUnixNano: String(record.startUnixNano),
128
+ endTimeUnixNano: String(record.endUnixNano),
129
+ attributes: toOtlpAttributes(record.attributes),
130
+ events: record.events.map(toOtlpEvent),
131
+ status: {
132
+ code: otelStatus,
133
+ ...(record.statusMessage === undefined ? {} : { message: record.statusMessage }),
134
+ },
135
+ },
136
+ ],
137
+ },
138
+ ],
139
+ },
140
+ ],
141
+ };
142
+ }
143
+
144
+ function spanKindFor(type: SpanType): number {
145
+ // OTel SpanKind enum: 0 unspecified, 1 internal, 2 server, 3 client, 4 producer, 5 consumer.
146
+ if (type === 'provider.generate' || type === 'provider.stream' || type === 'mcp.call') return 3;
147
+ if (type === 'tool.execute' || type === 'memory.embed') return 1;
148
+ return 1;
149
+ }
150
+
151
+ function toOtlpAttributes(attrs: SpanAttributes): unknown[] {
152
+ return Object.entries(attrs).map(([k, v]) => ({ key: k, value: toOtlpAttributeValue(v) }));
153
+ }
154
+
155
+ function toOtlpEvent(event: SpanRecordEvent): unknown {
156
+ return {
157
+ timeUnixNano: String(event.timeUnixNano),
158
+ name: event.name,
159
+ attributes: toOtlpAttributes(event.attributes),
160
+ };
161
+ }
162
+
163
+ function toOtlpAttributeValue(v: SpanAttributeValue): unknown {
164
+ if (typeof v === 'string') return { stringValue: v };
165
+ if (typeof v === 'number') {
166
+ return Number.isInteger(v) ? { intValue: String(v) } : { doubleValue: v };
167
+ }
168
+ if (typeof v === 'boolean') return { boolValue: v };
169
+ if (v === null) return { stringValue: '' };
170
+ if (Array.isArray(v)) {
171
+ return {
172
+ arrayValue: {
173
+ values: v.map((item) => toOtlpAttributeValue(item as SpanAttributeValue)),
174
+ },
175
+ };
176
+ }
177
+ return { stringValue: String(v) };
178
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Shared types for the exporter surface.
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+
7
+ import type { SpanAttributes, SpanAttributeValue, SpanStatus, SpanType } from '@graphorin/core';
8
+
9
+ /**
10
+ * Discriminator marker - every exporter that has been wrapped via
11
+ * `withValidation(...)` is branded with this symbol so the tracer can
12
+ * fail-fast at startup when a raw exporter is registered.
13
+ *
14
+ * @stable
15
+ */
16
+ export const VALIDATED_EXPORTER_BRAND: unique symbol = Symbol.for(
17
+ 'graphorin.observability.exporter.validated',
18
+ );
19
+
20
+ /**
21
+ * Sanitized, JSON-serialisable representation of a finished span. The
22
+ * exporters never see the live OTel span; the tracer materialises this
23
+ * record once the span ends and runs it through the validator.
24
+ *
25
+ * @stable
26
+ */
27
+ export interface SpanRecord<T extends SpanType = SpanType> {
28
+ readonly type: T;
29
+ readonly id: string;
30
+ readonly traceId: string;
31
+ readonly parentId?: string;
32
+ readonly name: string;
33
+ readonly startUnixNano: number;
34
+ readonly endUnixNano: number;
35
+ readonly status: SpanStatus;
36
+ readonly statusMessage?: string;
37
+ readonly attributes: SpanAttributes;
38
+ readonly events: ReadonlyArray<SpanRecordEvent>;
39
+ /** Optional per-attribute sensitivity map - see `setAttribute({ sensitivity })`. */
40
+ readonly sensitivityByAttribute?: Readonly<Record<string, SpanAttributeValue>>;
41
+ /** Set when the validator dropped the span entirely (replay marker). */
42
+ readonly droppedReason?: string;
43
+ }
44
+
45
+ /**
46
+ * Single span event carried alongside the span record.
47
+ *
48
+ * @stable
49
+ */
50
+ export interface SpanRecordEvent {
51
+ readonly name: string;
52
+ readonly timeUnixNano: number;
53
+ readonly attributes: SpanAttributes;
54
+ /**
55
+ * W-094: per-attribute sensitivity map recorded by
56
+ * `addEvent(name, attrs, { sensitivity, sensitivityByAttribute })`.
57
+ * Consumed by the validation exporter; absent ⇒ every attribute is
58
+ * untagged (default-deny below the export floor).
59
+ */
60
+ readonly sensitivityByAttribute?: Readonly<Record<string, 'public' | 'internal' | 'secret'>>;
61
+ }
62
+
63
+ /**
64
+ * A trace exporter contract. Exporters consume a stream of finished
65
+ * spans and forward them to a sink (console, file, OTLP wire, …).
66
+ *
67
+ * @stable
68
+ */
69
+ export interface TraceExporter {
70
+ readonly id: string;
71
+ /** Forward a finished span record. Implementations should be cheap. */
72
+ export(record: SpanRecord): Promise<void>;
73
+ /** Flush any buffered spans. Called on `tracer.shutdown()`. */
74
+ flush(): Promise<void>;
75
+ /** Close any underlying resources. Idempotent. */
76
+ shutdown(): Promise<void>;
77
+ /**
78
+ * Branded-marker stub. Set by `withValidation(...)` to signal that the
79
+ * exporter has been wrapped. Direct exporters omit the brand and the
80
+ * tracer fails fast at startup.
81
+ *
82
+ * @internal
83
+ */
84
+ readonly [VALIDATED_EXPORTER_BRAND]?: true;
85
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * `withValidation(exporter, opts)` - the **mandatory wrapper** applied
3
+ * to every exporter. Forwards each span record through a
4
+ * `RedactionValidator` first; drops + counts when a value exceeds the
5
+ * configured tier floor or matches a secret / PII pattern.
6
+ *
7
+ * @packageDocumentation
8
+ */
9
+
10
+ import type { Sensitivity, SpanAttributes, SpanAttributeValue } from '@graphorin/core';
11
+ import type {
12
+ RedactionCounters,
13
+ RedactionValidatorInstance,
14
+ RedactionValidatorOptions,
15
+ } from '../redaction/types.js';
16
+ import { createRedactionValidator } from '../redaction/validator.js';
17
+
18
+ import { type SpanRecord, type TraceExporter, VALIDATED_EXPORTER_BRAND } from './types.js';
19
+
20
+ const DEFAULT_ATTR_SENSITIVITY: Sensitivity = 'internal';
21
+
22
+ /**
23
+ * Options for {@link withValidation}.
24
+ *
25
+ * @stable
26
+ */
27
+ export interface WithValidationOptions extends RedactionValidatorOptions {
28
+ /**
29
+ * Optional pre-built validator. When supplied, the rest of the
30
+ * options on this object are ignored and the supplied validator is
31
+ * reused - useful for sharing one validator across multiple exporters.
32
+ */
33
+ readonly validator?: RedactionValidatorInstance;
34
+ }
35
+
36
+ /**
37
+ * Wrap an exporter so every span flows through a {@link RedactionValidator}
38
+ * before reaching the sink. Exporters that are not wrapped are rejected
39
+ * by the tracer at startup.
40
+ *
41
+ * @stable
42
+ */
43
+ export function withValidation<E extends TraceExporter>(
44
+ exporter: E,
45
+ opts: WithValidationOptions = {},
46
+ ): TraceExporter {
47
+ const validator = opts.validator ?? createRedactionValidator(opts);
48
+ const wrapped: TraceExporter = {
49
+ id: `${exporter.id}+validated`,
50
+ [VALIDATED_EXPORTER_BRAND]: true,
51
+ async export(record: SpanRecord): Promise<void> {
52
+ // RP-18: the validator strips offending attributes (counting each drop)
53
+ // and always returns an exportable record - a single untagged or
54
+ // over-tier attribute no longer makes the whole span vanish from every
55
+ // exporter.
56
+ await exporter.export(sanitizeRecord(record, validator));
57
+ },
58
+ flush: () => exporter.flush(),
59
+ shutdown: () => exporter.shutdown(),
60
+ };
61
+ return wrapped;
62
+ }
63
+
64
+ /**
65
+ * @internal
66
+ */
67
+ export function sanitizeRecord(
68
+ record: SpanRecord,
69
+ validator: RedactionValidatorInstance,
70
+ ): SpanRecord {
71
+ const sensitivities = record.sensitivityByAttribute ?? {};
72
+ // RP-18: attribute-granular sanitization. Offending attributes are stripped
73
+ // (and counted by the validator); the span itself always survives so
74
+ // framework spans carrying untagged attributes still reach every exporter.
75
+ const sanitizedAttrs = sanitizeAttributes(
76
+ record.attributes,
77
+ sensitivities,
78
+ validator,
79
+ record.type,
80
+ );
81
+ const sanitizedEvents = sanitizeEvents(record.events, validator, record.type);
82
+ return {
83
+ ...record,
84
+ attributes: sanitizedAttrs,
85
+ events: sanitizedEvents,
86
+ };
87
+ }
88
+
89
+ /**
90
+ * @internal
91
+ */
92
+ export function sanitizeAttributes(
93
+ attrs: SpanAttributes,
94
+ sensitivities: Readonly<Record<string, SpanAttributeValue>>,
95
+ validator: RedactionValidatorInstance,
96
+ spanType?: string,
97
+ origin?: string,
98
+ ): SpanAttributes {
99
+ const out: Record<string, SpanAttributeValue> = {};
100
+ for (const [key, value] of Object.entries(attrs)) {
101
+ const tier = readTier(sensitivities[key]);
102
+ const result = validator.validate({
103
+ value,
104
+ tier,
105
+ context: {
106
+ attribute: key,
107
+ ...(spanType !== undefined ? { spanType } : {}),
108
+ // W-094: lets onViolation / droppedByReason tell an EVENT
109
+ // attribute from a span attribute.
110
+ ...(origin !== undefined ? { origin } : {}),
111
+ },
112
+ });
113
+ if (result === null) {
114
+ // RP-18: this single attribute exceeded the floor (or matched a secret
115
+ // pattern). Strip it - the validator has already counted the drop - and
116
+ // keep the rest of the span rather than discarding the whole record.
117
+ continue;
118
+ }
119
+ out[key] = result.value as SpanAttributeValue;
120
+ }
121
+ return Object.freeze(out) as SpanAttributes;
122
+ }
123
+
124
+ /**
125
+ * @internal
126
+ */
127
+ export function sanitizeEvents(
128
+ events: SpanRecord['events'],
129
+ validator: RedactionValidatorInstance,
130
+ spanType?: string,
131
+ ): SpanRecord['events'] {
132
+ const out: SpanRecord['events'][number][] = [];
133
+ for (const event of events) {
134
+ // W-094: honour the per-event sensitivity map recorded by
135
+ // `addEvent(..., opts)` (pre-fix an empty map was passed here, so
136
+ // EVERY event attribute - including `exception.type` - was dropped
137
+ // under the default 'public' floor). Untagged attributes still
138
+ // default-deny; the `event:<name>` origin distinguishes event drops
139
+ // from span-attribute drops in onViolation.
140
+ const sanitizedAttrs = sanitizeAttributes(
141
+ event.attributes,
142
+ event.sensitivityByAttribute ?? {},
143
+ validator,
144
+ spanType,
145
+ `event:${event.name}`,
146
+ );
147
+ out.push({ name: event.name, timeUnixNano: event.timeUnixNano, attributes: sanitizedAttrs });
148
+ }
149
+ return out;
150
+ }
151
+
152
+ function readTier(value: SpanAttributeValue | undefined): Sensitivity {
153
+ if (value === 'public' || value === 'internal' || value === 'secret') return value;
154
+ return DEFAULT_ATTR_SENSITIVITY;
155
+ }
156
+
157
+ /**
158
+ * Quickly check whether an exporter is the result of a previous
159
+ * {@link withValidation} call. The tracer uses this to fail fast at
160
+ * startup.
161
+ *
162
+ * @stable
163
+ */
164
+ export function isValidatedExporter(exporter: TraceExporter): boolean {
165
+ return exporter[VALIDATED_EXPORTER_BRAND] === true;
166
+ }
167
+
168
+ /**
169
+ * Pull the counters out of any exporter wrapped by {@link withValidation}.
170
+ * Returns `null` for exporters that were never wrapped.
171
+ *
172
+ * @stable
173
+ */
174
+ export function tryGetValidatorCounters(validator: RedactionValidatorInstance): RedactionCounters {
175
+ return validator.counters();
176
+ }
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Emission helpers that attach OpenTelemetry GenAI semantic-convention
3
+ * attributes / events to an existing span.
4
+ *
5
+ * The helpers are deliberately additive - they NEVER remove or rename
6
+ * existing attributes. The new `gen_ai.*` family sits on top of the
7
+ * Graphorin-prefixed family. Sensitivity defaults follow the catalogue
8
+ * documented alongside the canonical mapping table:
9
+ *
10
+ * - `gen_ai.system` / `gen_ai.request.model` / `gen_ai.response.model` /
11
+ * `gen_ai.response.id` / `gen_ai.usage.{input,output}_tokens` /
12
+ * `gen_ai.response.finish_reasons` / `gen_ai.tool.{name,type,call.id,description}` /
13
+ * `gen_ai.agent.{id,name}` / `gen_ai.session.id` /
14
+ * `gen_ai.operation.name` → `'public'`
15
+ * - `gen_ai.request.messages` / per-message events / `gen_ai.response.content`
16
+ * → `'internal'` (default-deny non-public per DEC-141)
17
+ * - `gen_ai.tool.output` → inherits per-tool `Tool.sensitivity`
18
+ *
19
+ * @packageDocumentation
20
+ */
21
+
22
+ import type { AISpan, SpanAttributes, SpanAttributeValue, SpanType } from '@graphorin/core';
23
+
24
+ import type { GraphorinSpan } from '../tracer/span.js';
25
+ import { asGraphorinSpan } from '../tracer/tracer.js';
26
+
27
+ import { operationNameFor } from './operation-mapping.js';
28
+ import type { GenAIAttributes, GenAIMessage, GenAIMessageRole } from './types.js';
29
+
30
+ const PUBLIC_KEY_PREFIXES = [
31
+ 'gen_ai.system',
32
+ 'gen_ai.request.model',
33
+ 'gen_ai.response.model',
34
+ 'gen_ai.response.id',
35
+ 'gen_ai.response.finish_reasons',
36
+ 'gen_ai.usage.input_tokens',
37
+ 'gen_ai.usage.output_tokens',
38
+ 'gen_ai.tool.name',
39
+ 'gen_ai.tool.type',
40
+ 'gen_ai.tool.call.id',
41
+ 'gen_ai.tool.description',
42
+ 'gen_ai.agent.id',
43
+ 'gen_ai.agent.name',
44
+ 'gen_ai.session.id',
45
+ 'gen_ai.operation.name',
46
+ ] as const;
47
+
48
+ /**
49
+ * Attach the canonical `gen_ai.*` attribute set to a span. The helper
50
+ * is additive on the existing Graphorin-prefixed attributes and applies
51
+ * the per-attribute sensitivity defaults catalogue.
52
+ *
53
+ * @stable
54
+ */
55
+ export function emitGenAIAttributes<T extends SpanType>(
56
+ span: AISpan<T>,
57
+ attrs: GenAIAttributes,
58
+ ): void {
59
+ const gs = asGraphorinSpan(span);
60
+ const operation = attrs.operation ?? operationNameFor(span.type);
61
+ if (operation !== undefined) setPublic(gs, span, 'gen_ai.operation.name', operation);
62
+
63
+ if (attrs.system !== undefined) setPublic(gs, span, 'gen_ai.system', attrs.system);
64
+ if (attrs.requestModel !== undefined)
65
+ setPublic(gs, span, 'gen_ai.request.model', attrs.requestModel);
66
+ if (attrs.responseModel !== undefined)
67
+ setPublic(gs, span, 'gen_ai.response.model', attrs.responseModel);
68
+ if (attrs.responseId !== undefined) setPublic(gs, span, 'gen_ai.response.id', attrs.responseId);
69
+ if (attrs.inputTokens !== undefined)
70
+ setPublic(gs, span, 'gen_ai.usage.input_tokens', attrs.inputTokens);
71
+ if (attrs.outputTokens !== undefined)
72
+ setPublic(gs, span, 'gen_ai.usage.output_tokens', attrs.outputTokens);
73
+ if (attrs.finishReasons !== undefined)
74
+ setPublic(gs, span, 'gen_ai.response.finish_reasons', [...attrs.finishReasons]);
75
+ if (attrs.agentId !== undefined) setPublic(gs, span, 'gen_ai.agent.id', attrs.agentId);
76
+ if (attrs.agentName !== undefined) setPublic(gs, span, 'gen_ai.agent.name', attrs.agentName);
77
+ if (attrs.sessionId !== undefined) setPublic(gs, span, 'gen_ai.session.id', attrs.sessionId);
78
+ if (attrs.toolName !== undefined) setPublic(gs, span, 'gen_ai.tool.name', attrs.toolName);
79
+ if (attrs.toolType !== undefined) setPublic(gs, span, 'gen_ai.tool.type', attrs.toolType);
80
+ if (attrs.toolCallId !== undefined) setPublic(gs, span, 'gen_ai.tool.call.id', attrs.toolCallId);
81
+ if (attrs.toolDescription !== undefined)
82
+ setPublic(gs, span, 'gen_ai.tool.description', attrs.toolDescription);
83
+ }
84
+
85
+ /**
86
+ * Emit per-message OpenTelemetry GenAI span events. The helper records
87
+ * one event per message - the per-message-event emission shape per the
88
+ * OTel semconv discipline (size-bounded individually; safer than the
89
+ * aggregate-attribute shape on large prompts).
90
+ *
91
+ * @stable
92
+ */
93
+ export function emitGenAIMessageEvents<T extends SpanType>(
94
+ span: AISpan<T>,
95
+ messages: ReadonlyArray<GenAIMessage>,
96
+ opts: { readonly system?: string } = {},
97
+ ): void {
98
+ for (const message of messages) {
99
+ // W-094: structural metadata (role, provider name, message name,
100
+ // tool-call id) is 'public' so a message event survives the default
101
+ // export floor as a marker; the CONTENT (and tool-call payloads)
102
+ // stays 'internal' - exported only when the operator opted the
103
+ // floor up, with the PII patterns applying there.
104
+ span.addEvent(eventNameFor(message.role), buildEventAttrs(message, opts.system), {
105
+ sensitivity: 'internal',
106
+ sensitivityByAttribute: {
107
+ 'gen_ai.message.role': 'public',
108
+ 'gen_ai.system': 'public',
109
+ 'gen_ai.message.name': 'public',
110
+ 'gen_ai.tool.call.id': 'public',
111
+ },
112
+ });
113
+ }
114
+ }
115
+
116
+ function eventNameFor(role: GenAIMessageRole): string {
117
+ switch (role) {
118
+ case 'system':
119
+ return 'gen_ai.system.message';
120
+ case 'user':
121
+ return 'gen_ai.user.message';
122
+ case 'assistant':
123
+ return 'gen_ai.assistant.message';
124
+ case 'tool':
125
+ return 'gen_ai.tool.message';
126
+ }
127
+ }
128
+
129
+ function buildEventAttrs(message: GenAIMessage, system?: string): SpanAttributes {
130
+ const out: Record<string, SpanAttributeValue> = {
131
+ 'gen_ai.message.role': message.role,
132
+ content: message.content,
133
+ };
134
+ if (system !== undefined) out['gen_ai.system'] = system;
135
+ if (message.name !== undefined) out['gen_ai.message.name'] = message.name;
136
+ if (message.toolCallId !== undefined) out['gen_ai.tool.call.id'] = message.toolCallId;
137
+ if (message.toolCalls !== undefined && message.toolCalls.length > 0) {
138
+ out['gen_ai.tool.calls'] = JSON.stringify(message.toolCalls);
139
+ }
140
+ return Object.freeze(out) as SpanAttributes;
141
+ }
142
+
143
+ function setPublic(
144
+ gs: GraphorinSpan | null,
145
+ span: AISpan,
146
+ key: string,
147
+ value: SpanAttributeValue,
148
+ ): void {
149
+ if (
150
+ gs !== null &&
151
+ PUBLIC_KEY_PREFIXES.some((prefix) => key === prefix || key.startsWith(`${prefix}.`))
152
+ ) {
153
+ gs.setAttribute(key, value, { sensitivity: 'public' });
154
+ return;
155
+ }
156
+ span.setAttributes({ [key]: value });
157
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * OpenTelemetry GenAI semantic-conventions conformance helpers.
3
+ *
4
+ * @packageDocumentation
5
+ */
6
+
7
+ export { emitGenAIAttributes, emitGenAIMessageEvents } from './emit.js';
8
+ export {
9
+ OPERATION_NAME_TABLE,
10
+ operationNameFor,
11
+ } from './operation-mapping.js';
12
+ export {
13
+ _resetGenAISystemWarningsForTesting,
14
+ deriveGenAISystem,
15
+ PROVIDER_CLASS_TO_GEN_AI_SYSTEM,
16
+ setGenAISystemWarnSink,
17
+ } from './system-derivation.js';
18
+ export type {
19
+ GenAIAttributes,
20
+ GenAIMessage,
21
+ GenAIMessageRole,
22
+ GenAIOperationName,
23
+ GenAISystem,
24
+ GenAIToolType,
25
+ SpanTypeToOperationName,
26
+ } from './types.js';