@graphorin/observability 0.6.1 → 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 (80) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +24 -4
  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/types.d.ts +7 -0
  15. package/dist/exporters/types.d.ts.map +1 -1
  16. package/dist/exporters/types.js.map +1 -1
  17. package/dist/exporters/with-validation.d.ts.map +1 -1
  18. package/dist/exporters/with-validation.js +5 -4
  19. package/dist/exporters/with-validation.js.map +1 -1
  20. package/dist/gen-ai/emit.js +9 -1
  21. package/dist/gen-ai/emit.js.map +1 -1
  22. package/dist/index.d.ts +2 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +2 -8
  25. package/dist/index.js.map +1 -1
  26. package/dist/package.js +1 -1
  27. package/dist/package.js.map +1 -1
  28. package/dist/redaction/imperative-patterns.d.ts +3 -3
  29. package/dist/redaction/imperative-patterns.d.ts.map +1 -1
  30. package/dist/redaction/imperative-patterns.js +7 -0
  31. package/dist/redaction/imperative-patterns.js.map +1 -1
  32. package/dist/tracer/sampling.d.ts +6 -1
  33. package/dist/tracer/sampling.d.ts.map +1 -1
  34. package/dist/tracer/sampling.js +7 -1
  35. package/dist/tracer/sampling.js.map +1 -1
  36. package/dist/tracer/span.d.ts.map +1 -1
  37. package/dist/tracer/span.js +12 -3
  38. package/dist/tracer/span.js.map +1 -1
  39. package/package.json +18 -35
  40. package/src/cost/cost-tracker.ts +315 -0
  41. package/src/cost/delegate.ts +79 -0
  42. package/src/cost/index.ts +23 -0
  43. package/src/cost/types.ts +151 -0
  44. package/src/eval/index.ts +16 -0
  45. package/src/eval/runner.ts +146 -0
  46. package/src/eval/types.ts +122 -0
  47. package/src/exporters/console.ts +75 -0
  48. package/src/exporters/index.ts +36 -0
  49. package/src/exporters/jsonl.ts +167 -0
  50. package/src/exporters/otlp-http.ts +178 -0
  51. package/src/exporters/types.ts +85 -0
  52. package/src/exporters/with-validation.ts +176 -0
  53. package/src/gen-ai/emit.ts +157 -0
  54. package/src/gen-ai/index.ts +26 -0
  55. package/src/gen-ai/operation-mapping.ts +89 -0
  56. package/src/gen-ai/system-derivation.ts +84 -0
  57. package/src/gen-ai/types.ts +143 -0
  58. package/src/index.ts +36 -0
  59. package/src/logger/index.ts +13 -0
  60. package/src/logger/logger.ts +178 -0
  61. package/src/openinference/index.ts +133 -0
  62. package/src/redaction/config.ts +50 -0
  63. package/src/redaction/errors.ts +53 -0
  64. package/src/redaction/imperative-patterns.ts +268 -0
  65. package/src/redaction/index.ts +42 -0
  66. package/src/redaction/patterns.ts +263 -0
  67. package/src/redaction/types.ts +116 -0
  68. package/src/redaction/validator.ts +302 -0
  69. package/src/replay/config.ts +58 -0
  70. package/src/replay/index.ts +17 -0
  71. package/src/replay/log.ts +89 -0
  72. package/src/replay/replay.ts +196 -0
  73. package/src/replay/types.ts +112 -0
  74. package/src/telemetry/index.ts +94 -0
  75. package/src/tracer/ids.ts +27 -0
  76. package/src/tracer/index.ts +22 -0
  77. package/src/tracer/sampling.ts +170 -0
  78. package/src/tracer/span-names.ts +52 -0
  79. package/src/tracer/span.ts +175 -0
  80. package/src/tracer/tracer.ts +309 -0
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Concrete `AISpan<T>` implementation. The span is intentionally
3
+ * decoupled from `@opentelemetry/api`: the tracer materialises a
4
+ * {@link SpanRecord} on `end()` and lets exporters do the rest.
5
+ *
6
+ * @packageDocumentation
7
+ */
8
+
9
+ import type {
10
+ AddEventOptions,
11
+ AISpan,
12
+ Sensitivity,
13
+ SpanAttributes,
14
+ SpanAttributeValue,
15
+ SpanStatus,
16
+ SpanType,
17
+ } from '@graphorin/core';
18
+
19
+ import type { SpanRecord } from '../exporters/types.js';
20
+
21
+ /**
22
+ * Optional metadata accepted by `setAttribute(...)` to declare the
23
+ * sensitivity of a single attribute. The validator uses the declared
24
+ * tier when deciding whether to drop the attribute.
25
+ *
26
+ * @stable
27
+ */
28
+ export interface SetAttributeOptions {
29
+ readonly sensitivity?: Sensitivity;
30
+ }
31
+
32
+ /**
33
+ * The internal span carries the convenience `setAttribute(...)` method
34
+ * exposed by the tracer surface (per-attribute sensitivity tagging) on
35
+ * top of the standard {@link AISpan} contract.
36
+ *
37
+ * @stable
38
+ */
39
+ export interface GraphorinSpan<T extends SpanType = SpanType> extends AISpan<T> {
40
+ setAttribute(name: string, value: SpanAttributeValue, opts?: SetAttributeOptions): void;
41
+ }
42
+
43
+ /**
44
+ * @internal - sink invoked when a span ends.
45
+ */
46
+ export type SpanSink = (record: SpanRecord) => void;
47
+
48
+ /**
49
+ * @internal - parameters passed by the tracer to {@link createSpan}.
50
+ */
51
+ export interface SpanCreateInput<T extends SpanType = SpanType> {
52
+ readonly type: T;
53
+ readonly name: string;
54
+ readonly traceId: string;
55
+ readonly id: string;
56
+ readonly parentId?: string;
57
+ readonly attrs?: SpanAttributes;
58
+ readonly attrSensitivities?: Readonly<Record<string, Sensitivity>>;
59
+ readonly sink: SpanSink;
60
+ readonly now: () => number;
61
+ readonly recordEvent: (name: string) => boolean;
62
+ }
63
+
64
+ /**
65
+ * @internal
66
+ */
67
+ export function createSpan<T extends SpanType>(input: SpanCreateInput<T>): GraphorinSpan<T> {
68
+ const startUnixNano = Math.floor(input.now() * 1_000_000);
69
+ const attributes: Record<string, SpanAttributeValue> = { ...(input.attrs ?? {}) };
70
+ const sensitivities: Record<string, Sensitivity> = { ...(input.attrSensitivities ?? {}) };
71
+ const events: SpanRecord['events'][number][] = [];
72
+ let status: SpanStatus = 'ok';
73
+ let statusMessage: string | undefined;
74
+ let ended = false;
75
+
76
+ const span: GraphorinSpan<T> = {
77
+ type: input.type,
78
+ id: input.id,
79
+ traceId: input.traceId,
80
+ ...(input.parentId === undefined ? {} : { parentId: input.parentId }),
81
+ setAttributes(attrs: SpanAttributes): void {
82
+ if (ended) return;
83
+ for (const [k, v] of Object.entries(attrs)) {
84
+ attributes[k] = v;
85
+ }
86
+ },
87
+ setAttribute(name: string, value: SpanAttributeValue, opts?: SetAttributeOptions): void {
88
+ if (ended) return;
89
+ attributes[name] = value;
90
+ if (opts?.sensitivity !== undefined) {
91
+ sensitivities[name] = opts.sensitivity;
92
+ }
93
+ },
94
+ addEvent(name: string, attrs?: SpanAttributes, opts?: AddEventOptions): void {
95
+ if (ended) return;
96
+ if (!input.recordEvent(name)) return;
97
+ // W-094: resolve the event default + per-attribute overrides into
98
+ // one map the validation exporter consumes. Untagged attributes
99
+ // stay untagged - default-deny below the floor is preserved.
100
+ const eventSensitivities: Record<string, 'public' | 'internal' | 'secret'> = {};
101
+ if (opts?.sensitivity !== undefined) {
102
+ for (const key of Object.keys(attrs ?? {})) {
103
+ eventSensitivities[key] = opts.sensitivity;
104
+ }
105
+ }
106
+ for (const [key, tier] of Object.entries(opts?.sensitivityByAttribute ?? {})) {
107
+ eventSensitivities[key] = tier;
108
+ }
109
+ events.push({
110
+ name,
111
+ timeUnixNano: Math.floor(input.now() * 1_000_000),
112
+ attributes: Object.freeze({ ...(attrs ?? {}) }) as SpanAttributes,
113
+ ...(Object.keys(eventSensitivities).length > 0
114
+ ? { sensitivityByAttribute: Object.freeze(eventSensitivities) }
115
+ : {}),
116
+ });
117
+ },
118
+ recordException(err: unknown): void {
119
+ if (ended) return;
120
+ const e = err as { name?: unknown; message?: unknown; stack?: unknown };
121
+ const attrs: SpanAttributes = {
122
+ 'exception.type': typeof e?.name === 'string' && e.name.length > 0 ? e.name : 'Error',
123
+ 'exception.message': typeof e?.message === 'string' ? e.message : String(err),
124
+ ...(typeof e?.stack === 'string' ? { 'exception.stacktrace': e.stack } : {}),
125
+ };
126
+ events.push({
127
+ name: 'exception',
128
+ timeUnixNano: Math.floor(input.now() * 1_000_000),
129
+ attributes: attrs,
130
+ // W-094: the CLASS NAME of an error is safe and load-bearing for
131
+ // out-of-the-box error dashboards - tag it 'public'. Message +
132
+ // stacktrace stay 'internal' (paritous with the un-sanitized
133
+ // statusMessage; the PII patterns still apply at that floor).
134
+ sensitivityByAttribute: Object.freeze({
135
+ 'exception.type': 'public',
136
+ 'exception.message': 'internal',
137
+ 'exception.stacktrace': 'internal',
138
+ }),
139
+ });
140
+ },
141
+ setStatus(s: SpanStatus, message?: string): void {
142
+ if (ended) return;
143
+ status = s;
144
+ if (message !== undefined) statusMessage = message;
145
+ },
146
+ end(): void {
147
+ if (ended) return;
148
+ ended = true;
149
+ const endUnixNano = Math.floor(input.now() * 1_000_000);
150
+ const record: SpanRecord<T> = {
151
+ type: input.type,
152
+ id: input.id,
153
+ traceId: input.traceId,
154
+ ...(input.parentId === undefined ? {} : { parentId: input.parentId }),
155
+ name: input.name,
156
+ startUnixNano,
157
+ endUnixNano,
158
+ status,
159
+ ...(statusMessage === undefined ? {} : { statusMessage }),
160
+ attributes: Object.freeze({ ...attributes }) as SpanAttributes,
161
+ events: Object.freeze([...events]) as SpanRecord['events'],
162
+ ...(Object.keys(sensitivities).length === 0
163
+ ? {}
164
+ : {
165
+ sensitivityByAttribute: Object.freeze({ ...sensitivities }) as Readonly<
166
+ Record<string, SpanAttributeValue>
167
+ >,
168
+ }),
169
+ };
170
+ input.sink(record);
171
+ },
172
+ };
173
+
174
+ return span;
175
+ }
@@ -0,0 +1,309 @@
1
+ /**
2
+ * `createTracer(...)` - the public entry point for the observability
3
+ * tracer. Wires together the {@link Sampler}, the
4
+ * {@link RedactionValidator}, and the registered {@link TraceExporter}s.
5
+ *
6
+ * @packageDocumentation
7
+ */
8
+
9
+ import type {
10
+ AISpan,
11
+ Sensitivity,
12
+ SpanAttributeValue,
13
+ SpanType,
14
+ StartSpanOptions,
15
+ Tracer,
16
+ } from '@graphorin/core';
17
+
18
+ import { createConsoleExporter } from '../exporters/console.js';
19
+ import type { SpanRecord, TraceExporter } from '../exporters/types.js';
20
+ import { isValidatedExporter, withValidation } from '../exporters/with-validation.js';
21
+ import { UnvalidatedExporterError } from '../redaction/errors.js';
22
+ import type { RedactionValidatorInstance, RedactionValidatorOptions } from '../redaction/types.js';
23
+ import { createRedactionValidator } from '../redaction/validator.js';
24
+
25
+ import { newSpanId, newTraceId } from './ids.js';
26
+ import { createSampler, type SamplingOptions } from './sampling.js';
27
+ import { createSpan, type GraphorinSpan } from './span.js';
28
+ import { spanNameFor } from './span-names.js';
29
+
30
+ /**
31
+ * Configuration shape consumed by {@link createTracer}.
32
+ *
33
+ * @stable
34
+ */
35
+ export interface TracerOptions {
36
+ /** Logical service name. Embedded in the OTLP `Resource`. */
37
+ readonly serviceName?: string;
38
+ /**
39
+ * Configured exporters. Each exporter MUST be wrapped via
40
+ * `withValidation(...)` before reaching the tracer - pass them
41
+ * through unwrapped to use the tracer-managed validator (set by
42
+ * `validation`) or pass already-wrapped exporters when you want
43
+ * per-exporter policies.
44
+ */
45
+ readonly exporters: ReadonlyArray<TraceExporter>;
46
+ /**
47
+ * Tracer-managed validator policy.
48
+ *
49
+ * - `RedactionValidatorOptions` (default): the tracer auto-wraps any
50
+ * un-wrapped exporter with `withValidation(...)` using these
51
+ * options.
52
+ * - `'off'`: NOT recommended. Skips auto-wrap, and the tracer logs a
53
+ * startup WARN to the supplied {@link warnSink}. Pre-wrapped
54
+ * exporters still flow through their validators.
55
+ *
56
+ * @default `{ minTier: 'public', failOnUnredactedSensitive: false }`
57
+ */
58
+ readonly validation?: RedactionValidatorOptions | 'off';
59
+ /** Sampler configuration. */
60
+ readonly sampling?: SamplingOptions;
61
+ /**
62
+ * Default sensitivity for attributes that omit `setAttribute(_, _, { sensitivity })`.
63
+ * Defaults to `'internal'` per the default-deny non-public posture.
64
+ */
65
+ readonly defaultAttributeSensitivity?: Sensitivity;
66
+ /**
67
+ * Sink used for startup WARNings. Defaults to `console.warn`.
68
+ *
69
+ * @internal
70
+ */
71
+ readonly warnSink?: (line: string) => void;
72
+ /**
73
+ * Override the wall clock used for span timestamps. Returns
74
+ * milliseconds-since-epoch as a `number`. Default: `Date.now`.
75
+ *
76
+ * @internal
77
+ */
78
+ readonly now?: () => number;
79
+ }
80
+
81
+ /**
82
+ * The {@link createTracer} return value extends the standard
83
+ * {@link Tracer} contract from `@graphorin/core` with introspection
84
+ * helpers (counter snapshots, validator handle).
85
+ *
86
+ * @stable
87
+ */
88
+ export interface GraphorinTracer extends Tracer {
89
+ /** Service name embedded in the OTLP resource. */
90
+ readonly serviceName: string;
91
+ /**
92
+ * Snapshot of the redaction counters (`droppedTotal`,
93
+ * `droppedByReason`, `matchesByPattern`) maintained by the
94
+ * tracer-managed validator.
95
+ */
96
+ getMetrics(): import('../redaction/types.js').RedactionCounters;
97
+ /** The tracer-managed validator. `null` when `validation: 'off'`. */
98
+ readonly validator: RedactionValidatorInstance | null;
99
+ /** Force-flush every registered exporter. */
100
+ flush(): Promise<void>;
101
+ }
102
+
103
+ const DEFAULT_VALIDATION: RedactionValidatorOptions = Object.freeze({
104
+ minTier: 'public',
105
+ failOnUnredactedSensitive: false,
106
+ });
107
+
108
+ /**
109
+ * Build a {@link GraphorinTracer} from the supplied options. Every
110
+ * exporter passed in must already be wrapped via
111
+ * `withValidation(...)` OR `validation` must be set to a concrete
112
+ * options object so the tracer can auto-wrap on your behalf.
113
+ * Registering a raw exporter while `validation: 'off'` triggers an
114
+ * {@link UnvalidatedExporterError} at startup.
115
+ *
116
+ * @stable
117
+ */
118
+ export function createTracer(opts: TracerOptions): GraphorinTracer {
119
+ const serviceName = opts.serviceName ?? 'graphorin';
120
+ const sampler = createSampler(opts.sampling ?? {});
121
+ const warnSink = opts.warnSink ?? ((line: string) => console.warn(line));
122
+ const now = opts.now ?? (() => Date.now());
123
+ const defaultAttrSensitivity: Sensitivity = opts.defaultAttributeSensitivity ?? 'internal';
124
+
125
+ const validationMode = opts.validation ?? DEFAULT_VALIDATION;
126
+
127
+ let validator: RedactionValidatorInstance | null = null;
128
+ let exporters: TraceExporter[] = [];
129
+
130
+ if (validationMode === 'off') {
131
+ warnSink(
132
+ "[graphorin/observability] WARN: validation: 'off' - exporters are NOT " +
133
+ 'auto-wrapped. Every exporter must call withValidation(...) explicitly. ' +
134
+ 'See ADR-035: OTLP RedactionValidator + default-deny non-public.',
135
+ );
136
+ for (const exporter of opts.exporters) {
137
+ if (!isValidatedExporter(exporter)) {
138
+ throw new UnvalidatedExporterError(exporter.id);
139
+ }
140
+ exporters.push(exporter);
141
+ }
142
+ } else {
143
+ validator = createRedactionValidator(validationMode);
144
+ for (const exporter of opts.exporters) {
145
+ exporters.push(
146
+ isValidatedExporter(exporter) ? exporter : withValidation(exporter, { validator }),
147
+ );
148
+ }
149
+ }
150
+
151
+ if (exporters.length === 0) {
152
+ // The tracer is still useful (it emits records to /dev/null) but
153
+ // we want to surface that explicitly so consumers don't think the
154
+ // logger is broken.
155
+ const fallback = withValidation(createConsoleExporter({ id: 'console-fallback' }), {
156
+ validator: validator ?? createRedactionValidator(DEFAULT_VALIDATION),
157
+ });
158
+ warnSink(
159
+ '[graphorin/observability] WARN: createTracer() received zero exporters. ' +
160
+ 'Falling back to a sanitized ConsoleExporter so spans are not lost.',
161
+ );
162
+ exporters = [fallback];
163
+ }
164
+
165
+ // RP-20: sink() exports are fire-and-forget; hold each promise so flush()
166
+ // can await it. Without this a span emitted moments before shutdown is lost
167
+ // when the exporter's closed-guard trips before its export() resolves.
168
+ const inFlight = new Set<Promise<void>>();
169
+
170
+ function sink(record: SpanRecord): void {
171
+ for (const exporter of exporters) {
172
+ const p = Promise.resolve()
173
+ .then(() => exporter.export(record))
174
+ .catch((err: unknown) => {
175
+ warnSink(
176
+ `[graphorin/observability] WARN: exporter ${exporter.id} failed: ${
177
+ err instanceof Error ? err.message : String(err)
178
+ }`,
179
+ );
180
+ });
181
+ inFlight.add(p);
182
+ void p.finally(() => {
183
+ inFlight.delete(p);
184
+ });
185
+ }
186
+ }
187
+
188
+ function startSpan<T extends SpanType>(spec: StartSpanOptions<T>): AISpan<T> {
189
+ const traceId = spec.parent?.traceId ?? newTraceId();
190
+ const id = newSpanId();
191
+ const parentId = spec.parent?.id;
192
+ // RP-19: a real parent-sampled flag for `'parent-based'` sampling. An
193
+ // unsampled parent is a noop span (no `setAttribute`), so `asGraphorinSpan`
194
+ // returns null - `parentId !== undefined` used to treat that as
195
+ // parent-sampled and record the child as an orphan.
196
+ const parentSampled =
197
+ spec.parent === undefined ? undefined : asGraphorinSpan(spec.parent) !== null;
198
+ const sampled = sampler.shouldSample(spec.type, parentSampled);
199
+ if (!sampled) {
200
+ return noopSpan<T>(spec.type, traceId, id, parentId);
201
+ }
202
+ const sensitivities = readAttrSensitivities(spec.attrs);
203
+ return createSpan<T>({
204
+ type: spec.type,
205
+ name: spanNameFor(spec.type, spec.attrs),
206
+ traceId,
207
+ id,
208
+ ...(parentId === undefined ? {} : { parentId }),
209
+ ...(spec.attrs === undefined ? {} : { attrs: spec.attrs }),
210
+ ...(sensitivities === undefined ? {} : { attrSensitivities: sensitivities }),
211
+ sink,
212
+ now,
213
+ recordEvent: (name) => sampler.shouldRecordEvent(name),
214
+ }) as unknown as AISpan<T>;
215
+ }
216
+
217
+ async function span<T extends SpanType, R>(
218
+ spec: StartSpanOptions<T>,
219
+ fn: (s: AISpan<T>) => R | Promise<R>,
220
+ ): Promise<R> {
221
+ const s = startSpan(spec);
222
+ try {
223
+ const out = await fn(s);
224
+ s.setStatus('ok');
225
+ return out;
226
+ } catch (err) {
227
+ s.recordException(err);
228
+ s.setStatus('error', err instanceof Error ? err.message : String(err));
229
+ throw err;
230
+ } finally {
231
+ s.end();
232
+ }
233
+ }
234
+
235
+ async function flush(): Promise<void> {
236
+ // RP-20: drain in-flight fire-and-forget exports first, then flush.
237
+ while (inFlight.size > 0) {
238
+ await Promise.all([...inFlight]);
239
+ }
240
+ await Promise.all(exporters.map((e) => e.flush()));
241
+ }
242
+
243
+ async function shutdown(): Promise<void> {
244
+ await flush();
245
+ await Promise.all(exporters.map((e) => e.shutdown()));
246
+ }
247
+
248
+ return {
249
+ startSpan,
250
+ span,
251
+ flush,
252
+ shutdown,
253
+ serviceName,
254
+ getMetrics: () =>
255
+ validator?.counters() ?? {
256
+ droppedTotal: 0,
257
+ droppedByReason: Object.freeze({}),
258
+ matchesByPattern: Object.freeze({}),
259
+ },
260
+ validator,
261
+ };
262
+
263
+ function readAttrSensitivities(
264
+ attrs: undefined | Readonly<Record<string, SpanAttributeValue>>,
265
+ ): Readonly<Record<string, Sensitivity>> | undefined {
266
+ if (attrs === undefined) return undefined;
267
+ // RP-19: initial attrs that omit an explicit `setAttribute(_, _, {
268
+ // sensitivity })` default to `defaultAttributeSensitivity`. Threading it
269
+ // here makes the knob effective - untagged framework attributes carry the
270
+ // configured tier instead of the validator's hardcoded fallback.
271
+ const out: Record<string, Sensitivity> = {};
272
+ for (const key of Object.keys(attrs)) out[key] = defaultAttrSensitivity;
273
+ return Object.freeze(out);
274
+ }
275
+
276
+ function noopSpan<T extends SpanType>(
277
+ type: T,
278
+ traceId: string,
279
+ id: string,
280
+ parentId?: string,
281
+ ): AISpan<T> {
282
+ const stub: AISpan<T> = {
283
+ type,
284
+ id,
285
+ traceId,
286
+ ...(parentId === undefined ? {} : { parentId }),
287
+ setAttributes() {},
288
+ addEvent() {},
289
+ recordException() {},
290
+ setStatus() {},
291
+ end() {},
292
+ };
293
+ return stub;
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Returns the underlying {@link GraphorinSpan} when `span` is a Graphorin
299
+ * span. Useful when callers want to reach the per-attribute sensitivity
300
+ * helper from a generic `AISpan<T>`.
301
+ *
302
+ * @stable
303
+ */
304
+ export function asGraphorinSpan<T extends SpanType>(span: AISpan<T>): GraphorinSpan<T> | null {
305
+ if (typeof (span as Partial<GraphorinSpan<T>>).setAttribute === 'function') {
306
+ return span as GraphorinSpan<T>;
307
+ }
308
+ return null;
309
+ }