@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.
- package/CHANGELOG.md +42 -0
- package/README.md +29 -5
- package/dist/cost/cost-tracker.d.ts.map +1 -1
- package/dist/cost/cost-tracker.js +34 -0
- package/dist/cost/cost-tracker.js.map +1 -1
- package/dist/cost/delegate.d.ts +40 -0
- package/dist/cost/delegate.d.ts.map +1 -0
- package/dist/cost/delegate.js +31 -0
- package/dist/cost/delegate.js.map +1 -0
- package/dist/cost/index.d.ts +2 -1
- package/dist/cost/index.js +2 -1
- package/dist/cost/types.d.ts +39 -0
- package/dist/cost/types.d.ts.map +1 -1
- package/dist/exporters/otlp-http.d.ts.map +1 -1
- package/dist/exporters/otlp-http.js +4 -2
- package/dist/exporters/otlp-http.js.map +1 -1
- package/dist/exporters/types.d.ts +7 -0
- package/dist/exporters/types.d.ts.map +1 -1
- package/dist/exporters/types.js.map +1 -1
- package/dist/exporters/with-validation.d.ts.map +1 -1
- package/dist/exporters/with-validation.js +5 -4
- package/dist/exporters/with-validation.js.map +1 -1
- package/dist/gen-ai/emit.js +9 -1
- package/dist/gen-ai/emit.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -10
- package/dist/index.js.map +1 -1
- package/dist/package.js +6 -0
- package/dist/package.js.map +1 -0
- package/dist/redaction/imperative-patterns.d.ts +3 -3
- package/dist/redaction/imperative-patterns.d.ts.map +1 -1
- package/dist/redaction/imperative-patterns.js +7 -0
- package/dist/redaction/imperative-patterns.js.map +1 -1
- package/dist/redaction/patterns.d.ts.map +1 -1
- package/dist/redaction/patterns.js +2 -2
- package/dist/redaction/patterns.js.map +1 -1
- package/dist/tracer/sampling.d.ts +6 -1
- package/dist/tracer/sampling.d.ts.map +1 -1
- package/dist/tracer/sampling.js +7 -1
- package/dist/tracer/sampling.js.map +1 -1
- package/dist/tracer/span.d.ts.map +1 -1
- package/dist/tracer/span.js +12 -3
- package/dist/tracer/span.js.map +1 -1
- package/package.json +18 -35
- package/src/cost/cost-tracker.ts +315 -0
- package/src/cost/delegate.ts +79 -0
- package/src/cost/index.ts +23 -0
- package/src/cost/types.ts +151 -0
- package/src/eval/index.ts +16 -0
- package/src/eval/runner.ts +146 -0
- package/src/eval/types.ts +122 -0
- package/src/exporters/console.ts +75 -0
- package/src/exporters/index.ts +36 -0
- package/src/exporters/jsonl.ts +167 -0
- package/src/exporters/otlp-http.ts +178 -0
- package/src/exporters/types.ts +85 -0
- package/src/exporters/with-validation.ts +176 -0
- package/src/gen-ai/emit.ts +157 -0
- package/src/gen-ai/index.ts +26 -0
- package/src/gen-ai/operation-mapping.ts +89 -0
- package/src/gen-ai/system-derivation.ts +84 -0
- package/src/gen-ai/types.ts +143 -0
- package/src/index.ts +36 -0
- package/src/logger/index.ts +13 -0
- package/src/logger/logger.ts +178 -0
- package/src/openinference/index.ts +133 -0
- package/src/redaction/config.ts +50 -0
- package/src/redaction/errors.ts +53 -0
- package/src/redaction/imperative-patterns.ts +268 -0
- package/src/redaction/index.ts +42 -0
- package/src/redaction/patterns.ts +263 -0
- package/src/redaction/types.ts +116 -0
- package/src/redaction/validator.ts +302 -0
- package/src/replay/config.ts +58 -0
- package/src/replay/index.ts +17 -0
- package/src/replay/log.ts +89 -0
- package/src/replay/replay.ts +196 -0
- package/src/replay/types.ts +112 -0
- package/src/telemetry/index.ts +94 -0
- package/src/tracer/ids.ts +27 -0
- package/src/tracer/index.ts +22 -0
- package/src/tracer/sampling.ts +170 -0
- package/src/tracer/span-names.ts +52 -0
- package/src/tracer/span.ts +175 -0
- package/src/tracer/tracer.ts +309 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `createReplay(...)` - sanitized-by-default replay primitives.
|
|
3
|
+
*
|
|
4
|
+
* Replay never opens the underlying file directly; callers feed it an
|
|
5
|
+
* iterable of `SpanRecord`s. The {@link createTraceLogReader} helper
|
|
6
|
+
* (file-backed JSONL) and `getTraceLog(...)` aggregate utility live
|
|
7
|
+
* alongside this module.
|
|
8
|
+
*
|
|
9
|
+
* @packageDocumentation
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Sensitivity } from '@graphorin/core';
|
|
13
|
+
|
|
14
|
+
import type { SpanRecord } from '../exporters/types.js';
|
|
15
|
+
import { sanitizeRecord } from '../exporters/with-validation.js';
|
|
16
|
+
import type { RedactionValidatorInstance } from '../redaction/types.js';
|
|
17
|
+
import { compareSensitivityTiers, createRedactionValidator } from '../redaction/validator.js';
|
|
18
|
+
|
|
19
|
+
import type {
|
|
20
|
+
ReplayAuditBridge,
|
|
21
|
+
ReplayAuditEvent,
|
|
22
|
+
ReplayEvent,
|
|
23
|
+
ReplayOptions,
|
|
24
|
+
ReplayRunInput,
|
|
25
|
+
} from './types.js';
|
|
26
|
+
|
|
27
|
+
const DEFAULT_VALIDATOR = createRedactionValidator({ minTier: 'public' });
|
|
28
|
+
const DEFAULT_ACTOR: ReplayAuditEvent['actor'] = { kind: 'system', id: 'in-process' };
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @stable
|
|
32
|
+
*/
|
|
33
|
+
export interface Replay {
|
|
34
|
+
run(input: ReplayRunInput): AsyncIterable<ReplayEvent>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Build a replay primitive. The returned object exposes a single
|
|
39
|
+
* `run(...)` async iterator that yields {@link ReplayEvent} records.
|
|
40
|
+
*
|
|
41
|
+
* Sanitized mode is the default and applies the configured
|
|
42
|
+
* {@link RedactionValidatorInstance} to every record. Raw mode
|
|
43
|
+
* requires the `canReadRaw` callback to return `true` AND emits an
|
|
44
|
+
* audit log entry on every invocation.
|
|
45
|
+
*
|
|
46
|
+
* @stable
|
|
47
|
+
*/
|
|
48
|
+
export function createReplay(opts: ReplayOptions = {}): Replay {
|
|
49
|
+
const validator: RedactionValidatorInstance = opts.validator ?? DEFAULT_VALIDATOR;
|
|
50
|
+
const audit: ReplayAuditBridge | undefined = opts.audit;
|
|
51
|
+
const defaultActor = opts.defaultActor ?? DEFAULT_ACTOR;
|
|
52
|
+
const canReadRaw = opts.canReadRaw ?? (() => true);
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
async *run(input: ReplayRunInput): AsyncIterable<ReplayEvent> {
|
|
56
|
+
const mode = input.mode ?? 'sanitized';
|
|
57
|
+
const minSensitivity: Sensitivity = input.minSensitivity ?? 'public';
|
|
58
|
+
const actor = input.actor ?? defaultActor;
|
|
59
|
+
const start = Date.now();
|
|
60
|
+
|
|
61
|
+
if (mode === 'raw' && !canReadRaw({ target: input.target })) {
|
|
62
|
+
emitAudit(audit, {
|
|
63
|
+
action: 'trace.replay.accessed',
|
|
64
|
+
actor,
|
|
65
|
+
target: input.target,
|
|
66
|
+
decision: 'denied',
|
|
67
|
+
metadata: { mode, minSensitivity, eventCount: 0, durationMs: Date.now() - start },
|
|
68
|
+
});
|
|
69
|
+
yield { type: 'replay.start', target: input.target, mode };
|
|
70
|
+
yield { type: 'replay.skipped', reason: 'access-denied', spanId: input.target };
|
|
71
|
+
yield {
|
|
72
|
+
type: 'replay.end',
|
|
73
|
+
durationMs: Date.now() - start,
|
|
74
|
+
eventsEmitted: 0,
|
|
75
|
+
eventsSkipped: 1,
|
|
76
|
+
};
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let emitted = 0;
|
|
81
|
+
let skipped = 0;
|
|
82
|
+
let started = false;
|
|
83
|
+
let reachedFrom = input.fromSpanId === undefined;
|
|
84
|
+
|
|
85
|
+
const iterator = isAsyncIterable(input.source)
|
|
86
|
+
? input.source[Symbol.asyncIterator]()
|
|
87
|
+
: asAsync(input.source[Symbol.iterator]());
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
while (true) {
|
|
91
|
+
const next = await iterator.next();
|
|
92
|
+
if (next.done === true) break;
|
|
93
|
+
const record = next.value;
|
|
94
|
+
|
|
95
|
+
if (!started) {
|
|
96
|
+
started = true;
|
|
97
|
+
yield { type: 'replay.start', target: input.target, mode };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!reachedFrom) {
|
|
101
|
+
if (record.id === input.fromSpanId) reachedFrom = true;
|
|
102
|
+
else continue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (mode === 'raw') {
|
|
106
|
+
yield { type: 'replay.event', span: record, sanitized: false };
|
|
107
|
+
emitted += 1;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (skipBySensitivity(record, minSensitivity)) {
|
|
112
|
+
skipped += 1;
|
|
113
|
+
yield { type: 'replay.skipped', reason: 'sensitivity', spanId: record.id };
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const sanitized = sanitizeRecord(record, validator);
|
|
118
|
+
// RP-18: sanitization is attribute-granular and never drops the whole
|
|
119
|
+
// span. A record arrives pre-flagged with `droppedReason` only if an
|
|
120
|
+
// upstream stage marked it; honour that, otherwise replay the
|
|
121
|
+
// stripped span.
|
|
122
|
+
if (sanitized.droppedReason !== undefined) {
|
|
123
|
+
skipped += 1;
|
|
124
|
+
yield {
|
|
125
|
+
type: 'replay.skipped',
|
|
126
|
+
reason: 'redaction-violation',
|
|
127
|
+
spanId: record.id,
|
|
128
|
+
};
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
yield { type: 'replay.event', span: sanitized, sanitized: true };
|
|
133
|
+
emitted += 1;
|
|
134
|
+
}
|
|
135
|
+
} finally {
|
|
136
|
+
const durationMs = Date.now() - start;
|
|
137
|
+
if (!started) {
|
|
138
|
+
yield { type: 'replay.start', target: input.target, mode };
|
|
139
|
+
}
|
|
140
|
+
emitAudit(audit, {
|
|
141
|
+
action: 'trace.replay.accessed',
|
|
142
|
+
actor,
|
|
143
|
+
target: input.target,
|
|
144
|
+
decision: 'success',
|
|
145
|
+
metadata: {
|
|
146
|
+
mode,
|
|
147
|
+
minSensitivity,
|
|
148
|
+
...(input.fromSpanId === undefined ? {} : { fromSpanId: input.fromSpanId }),
|
|
149
|
+
eventCount: emitted,
|
|
150
|
+
durationMs,
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
yield {
|
|
154
|
+
type: 'replay.end',
|
|
155
|
+
durationMs,
|
|
156
|
+
eventsEmitted: emitted,
|
|
157
|
+
eventsSkipped: skipped,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function emitAudit(bridge: ReplayAuditBridge | undefined, event: ReplayAuditEvent): void {
|
|
165
|
+
if (bridge === undefined) return;
|
|
166
|
+
try {
|
|
167
|
+
bridge.emit(event);
|
|
168
|
+
} catch {
|
|
169
|
+
// Audit listener errors must never break the replay pipeline.
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function skipBySensitivity(record: SpanRecord, floor: Sensitivity): boolean {
|
|
174
|
+
if (record.sensitivityByAttribute === undefined) return false;
|
|
175
|
+
for (const value of Object.values(record.sensitivityByAttribute)) {
|
|
176
|
+
if (typeof value === 'string') {
|
|
177
|
+
const tier = value as Sensitivity;
|
|
178
|
+
if (tier === 'public' || tier === 'internal' || tier === 'secret') {
|
|
179
|
+
if (compareSensitivityTiers(tier, floor) > 0) return true;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return false;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function isAsyncIterable<T>(value: unknown): value is AsyncIterable<T> {
|
|
187
|
+
return value !== null && typeof value === 'object' && Symbol.asyncIterator in (value as object);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function asAsync<T>(it: Iterator<T>): AsyncIterator<T> {
|
|
191
|
+
return {
|
|
192
|
+
next(): Promise<IteratorResult<T>> {
|
|
193
|
+
return Promise.resolve(it.next());
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public types for the replay surface.
|
|
3
|
+
*
|
|
4
|
+
* @packageDocumentation
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Sensitivity } from '@graphorin/core';
|
|
8
|
+
|
|
9
|
+
import type { SpanRecord } from '../exporters/types.js';
|
|
10
|
+
import type { RedactionValidatorInstance } from '../redaction/types.js';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Replay scope hint. Server mode requires `'raw'` to be backed by a
|
|
14
|
+
* token carrying the `traces:read:raw` scope. The library mode uses
|
|
15
|
+
* the same flag but without scope enforcement (the server is the only
|
|
16
|
+
* boundary that can grant `'raw'`).
|
|
17
|
+
*
|
|
18
|
+
* @stable
|
|
19
|
+
*/
|
|
20
|
+
export type ReplayMode = 'sanitized' | 'raw';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Audit-bridge contract used by the replay layer. Every replay
|
|
24
|
+
* invocation emits one entry through the bridge - sanitized + raw
|
|
25
|
+
* alike. The actual audit storage lives in `@graphorin/security`; the
|
|
26
|
+
* replay layer keeps the bridge generic so the package stays free of
|
|
27
|
+
* a hard dependency on the security package.
|
|
28
|
+
*
|
|
29
|
+
* @stable
|
|
30
|
+
*/
|
|
31
|
+
export interface ReplayAuditBridge {
|
|
32
|
+
emit(event: ReplayAuditEvent): void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Sanitized event passed to the audit bridge.
|
|
37
|
+
*
|
|
38
|
+
* @stable
|
|
39
|
+
*/
|
|
40
|
+
export interface ReplayAuditEvent {
|
|
41
|
+
readonly action: 'trace.replay.accessed';
|
|
42
|
+
readonly actor: { readonly kind: 'token' | 'cli' | 'agent' | 'system'; readonly id: string };
|
|
43
|
+
readonly target: string;
|
|
44
|
+
readonly decision: 'success' | 'denied';
|
|
45
|
+
readonly metadata: {
|
|
46
|
+
readonly mode: ReplayMode;
|
|
47
|
+
readonly minSensitivity: Sensitivity;
|
|
48
|
+
readonly fromSpanId?: string;
|
|
49
|
+
readonly eventCount: number;
|
|
50
|
+
readonly durationMs: number;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Single record yielded by the replay iterator.
|
|
56
|
+
*
|
|
57
|
+
* @stable
|
|
58
|
+
*/
|
|
59
|
+
export type ReplayEvent =
|
|
60
|
+
| {
|
|
61
|
+
readonly type: 'replay.start';
|
|
62
|
+
readonly target: string;
|
|
63
|
+
readonly mode: ReplayMode;
|
|
64
|
+
readonly eventCount?: number;
|
|
65
|
+
}
|
|
66
|
+
| { readonly type: 'replay.event'; readonly span: SpanRecord; readonly sanitized: boolean }
|
|
67
|
+
| {
|
|
68
|
+
readonly type: 'replay.skipped';
|
|
69
|
+
readonly reason: 'sensitivity' | 'redaction-violation' | 'access-denied';
|
|
70
|
+
readonly spanId: string;
|
|
71
|
+
}
|
|
72
|
+
| {
|
|
73
|
+
readonly type: 'replay.end';
|
|
74
|
+
readonly durationMs: number;
|
|
75
|
+
readonly eventsEmitted: number;
|
|
76
|
+
readonly eventsSkipped: number;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Configuration shape for {@link createReplay}.
|
|
81
|
+
*
|
|
82
|
+
* @stable
|
|
83
|
+
*/
|
|
84
|
+
export interface ReplayOptions {
|
|
85
|
+
/** Validator used to sanitize records when `mode === 'sanitized'`. */
|
|
86
|
+
readonly validator?: RedactionValidatorInstance;
|
|
87
|
+
/** Optional audit bridge - called once per replay invocation. */
|
|
88
|
+
readonly audit?: ReplayAuditBridge;
|
|
89
|
+
/** Default actor reported via `audit.actor` when none is supplied. */
|
|
90
|
+
readonly defaultActor?: ReplayAuditEvent['actor'];
|
|
91
|
+
/**
|
|
92
|
+
* Scope check invoked when the caller asks for `mode: 'raw'`. Returns
|
|
93
|
+
* `true` to allow, `false` to deny. The server (Phase 14) wires this
|
|
94
|
+
* to the `traces:read:raw` token scope; in library mode it defaults
|
|
95
|
+
* to `() => true` (operators trust their own process).
|
|
96
|
+
*/
|
|
97
|
+
readonly canReadRaw?: (context: { readonly target: string }) => boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Per-call options consumed by `Replay.run(...)`.
|
|
102
|
+
*
|
|
103
|
+
* @stable
|
|
104
|
+
*/
|
|
105
|
+
export interface ReplayRunInput {
|
|
106
|
+
readonly source: AsyncIterable<SpanRecord> | Iterable<SpanRecord>;
|
|
107
|
+
readonly target: string;
|
|
108
|
+
readonly mode?: ReplayMode;
|
|
109
|
+
readonly minSensitivity?: Sensitivity;
|
|
110
|
+
readonly fromSpanId?: string;
|
|
111
|
+
readonly actor?: ReplayAuditEvent['actor'];
|
|
112
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zero-default telemetry stub.
|
|
3
|
+
*
|
|
4
|
+
* The framework promises to make zero outbound network calls without
|
|
5
|
+
* an explicit user action. This module exists so consumers can:
|
|
6
|
+
*
|
|
7
|
+
* - inspect the telemetry posture from CLI / health endpoints,
|
|
8
|
+
* - register the reserved `GRAPHORIN_TELEMETRY` / `GRAPHORIN_NO_PHONE_HOME`
|
|
9
|
+
* environment variables for forward compatibility,
|
|
10
|
+
* - assert in tests that no implicit telemetry surface exists.
|
|
11
|
+
*
|
|
12
|
+
* Attempting to enable telemetry pre-v0.2 returns a sentinel result; it
|
|
13
|
+
* never opens a socket.
|
|
14
|
+
*
|
|
15
|
+
* @packageDocumentation
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Result returned by {@link getTelemetryStatus}.
|
|
20
|
+
*
|
|
21
|
+
* @stable
|
|
22
|
+
*/
|
|
23
|
+
export interface TelemetryStatus {
|
|
24
|
+
/** Always `false` in v0.1. Reserved field. */
|
|
25
|
+
readonly enabled: false;
|
|
26
|
+
/** Plain-English explanation of the current state. */
|
|
27
|
+
readonly reason: string;
|
|
28
|
+
/** Resolved value of `GRAPHORIN_TELEMETRY` (if any). */
|
|
29
|
+
readonly env?: string;
|
|
30
|
+
/** Resolved value of `GRAPHORIN_NO_PHONE_HOME` (if any). */
|
|
31
|
+
readonly noPhoneHome?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Snapshot of the telemetry posture. Reads from `process.env` once
|
|
36
|
+
* unless `env` is provided.
|
|
37
|
+
*
|
|
38
|
+
* @stable
|
|
39
|
+
*/
|
|
40
|
+
export function getTelemetryStatus(env: NodeJS.ProcessEnv = process.env): TelemetryStatus {
|
|
41
|
+
const telemetry = env.GRAPHORIN_TELEMETRY;
|
|
42
|
+
const noPhoneHome = env.GRAPHORIN_NO_PHONE_HOME;
|
|
43
|
+
return {
|
|
44
|
+
enabled: false,
|
|
45
|
+
reason: 'telemetry not yet implemented (v0.1 ships zero default telemetry)',
|
|
46
|
+
...(telemetry === undefined ? {} : { env: telemetry }),
|
|
47
|
+
...(noPhoneHome === undefined ? {} : { noPhoneHome }),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Best-effort enable hook. Always returns the sentinel
|
|
53
|
+
* `{ status: 'disabled', reason: ... }` payload. Reserved for v0.2+.
|
|
54
|
+
*
|
|
55
|
+
* @stable
|
|
56
|
+
*/
|
|
57
|
+
export function enableTelemetry(): {
|
|
58
|
+
readonly status: 'disabled';
|
|
59
|
+
readonly reason: string;
|
|
60
|
+
} {
|
|
61
|
+
return {
|
|
62
|
+
status: 'disabled',
|
|
63
|
+
reason: 'telemetry not yet implemented (v0.1 ships zero default telemetry)',
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Detect the reserved env vars and emit one informational line per
|
|
69
|
+
* process. Returns the lines as an array so callers can route them to
|
|
70
|
+
* any sink they like (defaults to `console.info`).
|
|
71
|
+
*
|
|
72
|
+
* @stable
|
|
73
|
+
*/
|
|
74
|
+
export function announceTelemetryPosture(
|
|
75
|
+
opts: { readonly env?: NodeJS.ProcessEnv; readonly sink?: (line: string) => void } = {},
|
|
76
|
+
): ReadonlyArray<string> {
|
|
77
|
+
const env = opts.env ?? process.env;
|
|
78
|
+
const sink = opts.sink ?? ((line: string) => console.info(line));
|
|
79
|
+
const lines: string[] = [];
|
|
80
|
+
if (env.GRAPHORIN_TELEMETRY !== undefined) {
|
|
81
|
+
lines.push(
|
|
82
|
+
`[graphorin/observability] info: GRAPHORIN_TELEMETRY=${env.GRAPHORIN_TELEMETRY} ` +
|
|
83
|
+
' - acknowledged. Telemetry is hardcoded `disabled` in v0.1.',
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
if (env.GRAPHORIN_NO_PHONE_HOME !== undefined) {
|
|
87
|
+
lines.push(
|
|
88
|
+
`[graphorin/observability] info: GRAPHORIN_NO_PHONE_HOME=${env.GRAPHORIN_NO_PHONE_HOME} ` +
|
|
89
|
+
' - acknowledged. Graphorin already makes zero outbound calls without explicit user action.',
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
for (const line of lines) sink(line);
|
|
93
|
+
return lines;
|
|
94
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Trace and span ID generators. The tracer prefers `crypto.randomUUID()`
|
|
3
|
+
* over a custom RNG for parity with the OpenTelemetry SDK behaviour and
|
|
4
|
+
* to avoid pulling another runtime dependency.
|
|
5
|
+
*
|
|
6
|
+
* @packageDocumentation
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { randomBytes } from 'node:crypto';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Generate a 32-hex-character (16-byte) trace id.
|
|
13
|
+
*
|
|
14
|
+
* @stable
|
|
15
|
+
*/
|
|
16
|
+
export function newTraceId(): string {
|
|
17
|
+
return randomBytes(16).toString('hex');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Generate a 16-hex-character (8-byte) span id.
|
|
22
|
+
*
|
|
23
|
+
* @stable
|
|
24
|
+
*/
|
|
25
|
+
export function newSpanId(): string {
|
|
26
|
+
return randomBytes(8).toString('hex');
|
|
27
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tracer surface for `@graphorin/observability`.
|
|
3
|
+
*
|
|
4
|
+
* @packageDocumentation
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export { newSpanId, newTraceId } from './ids.js';
|
|
8
|
+
export {
|
|
9
|
+
createSampler,
|
|
10
|
+
type Sampler,
|
|
11
|
+
type SamplingDecisionMaker,
|
|
12
|
+
type SamplingOptions,
|
|
13
|
+
type SamplingRule,
|
|
14
|
+
} from './sampling.js';
|
|
15
|
+
export type { GraphorinSpan, SetAttributeOptions } from './span.js';
|
|
16
|
+
export { spanNameFor } from './span-names.js';
|
|
17
|
+
export {
|
|
18
|
+
asGraphorinSpan,
|
|
19
|
+
createTracer,
|
|
20
|
+
type GraphorinTracer,
|
|
21
|
+
type TracerOptions,
|
|
22
|
+
} from './tracer.js';
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Head + tail sampling helpers. The tracer pairs the configured
|
|
3
|
+
* sampler with a per-type override registry so chatty span types
|
|
4
|
+
* (`memory.embed`, `tool.execute.partial`, …) can be downsampled
|
|
5
|
+
* without affecting the rest of the trace stream. Under the default
|
|
6
|
+
* `'parent-based'` decision maker the per-type rules apply to CHILDREN
|
|
7
|
+
* of a sampled trace too (W-090) - a rule can only downsample inside a
|
|
8
|
+
* sampled trace, never resurrect children of an unsampled parent.
|
|
9
|
+
*
|
|
10
|
+
* @packageDocumentation
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { SpanType } from '@graphorin/core';
|
|
14
|
+
|
|
15
|
+
/** @stable */
|
|
16
|
+
export type SamplingDecisionMaker = 'parent-based' | 'always-on' | 'rate-limit';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Per-span-type rate override. Applies on the probabilistic root path
|
|
20
|
+
* AND (W-090) to children of a sampled parent under `'parent-based'` -
|
|
21
|
+
* `{ type: 'tool.execute', rate: 0.01 }` thins the per-call spans
|
|
22
|
+
* inside every sampled `agent.run` trace. A child dropped by its rule
|
|
23
|
+
* breaks the tree below it: its own descendants inherit
|
|
24
|
+
* `parentSampled=false`.
|
|
25
|
+
*
|
|
26
|
+
* @stable
|
|
27
|
+
*/
|
|
28
|
+
export interface SamplingRule {
|
|
29
|
+
readonly type: SpanType | string;
|
|
30
|
+
readonly rate: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Configuration shape consumed by {@link createSampler}.
|
|
35
|
+
*
|
|
36
|
+
* @stable
|
|
37
|
+
*/
|
|
38
|
+
export interface SamplingOptions {
|
|
39
|
+
/** Default head-sampling rate. Must be in `[0, 1]`. Defaults to `1.0`. */
|
|
40
|
+
readonly rate?: number;
|
|
41
|
+
/** Per-type overrides. Last write wins on duplicate `type`. */
|
|
42
|
+
readonly rules?: ReadonlyArray<SamplingRule>;
|
|
43
|
+
/** Decision maker. Defaults to `'parent-based'`. */
|
|
44
|
+
readonly decisionMaker?: SamplingDecisionMaker;
|
|
45
|
+
/**
|
|
46
|
+
* Cap for the `'rate-limit'` decision maker: at most this many root spans
|
|
47
|
+
* are sampled per rolling 1-second window (RP-19). `undefined` ⇒ no cap
|
|
48
|
+
* (samples everything); `0` ⇒ sample nothing. Ignored by the other
|
|
49
|
+
* decision makers.
|
|
50
|
+
*/
|
|
51
|
+
readonly maxPerSecond?: number;
|
|
52
|
+
/**
|
|
53
|
+
* Clock for the `'rate-limit'` window. Defaults to `Date.now`.
|
|
54
|
+
*
|
|
55
|
+
* @internal
|
|
56
|
+
*/
|
|
57
|
+
readonly now?: () => number;
|
|
58
|
+
/**
|
|
59
|
+
* Optional override for streaming-event sampling.
|
|
60
|
+
* @see RB-52 - streaming event family `tool.execute.{progress,partial}`.
|
|
61
|
+
*/
|
|
62
|
+
readonly streaming?: {
|
|
63
|
+
readonly eventSamplingRate?: number;
|
|
64
|
+
readonly includeChunkContent?: 'none' | 'text-only' | 'all';
|
|
65
|
+
};
|
|
66
|
+
/**
|
|
67
|
+
* Override for the random source. Useful for deterministic tests.
|
|
68
|
+
*
|
|
69
|
+
* @internal
|
|
70
|
+
*/
|
|
71
|
+
readonly random?: () => number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* @stable
|
|
76
|
+
*/
|
|
77
|
+
export interface Sampler {
|
|
78
|
+
/** Decide whether a span of the given type should be recorded. */
|
|
79
|
+
shouldSample(type: SpanType | string, parentSampled?: boolean): boolean;
|
|
80
|
+
/** Decide whether a span event of the given name should be recorded. */
|
|
81
|
+
shouldRecordEvent(name: string): boolean;
|
|
82
|
+
/** Returns whether chunk *content* should travel through the exporter. */
|
|
83
|
+
shouldIncludeChunkContent(): boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build a {@link Sampler} from the supplied options. The sampler is
|
|
88
|
+
* intentionally inexpensive - every decision boils down to a single
|
|
89
|
+
* `random < threshold` comparison.
|
|
90
|
+
*
|
|
91
|
+
* @stable
|
|
92
|
+
*/
|
|
93
|
+
export function createSampler(opts: SamplingOptions = {}): Sampler {
|
|
94
|
+
const baseRate = clampRate(opts.rate ?? 1.0);
|
|
95
|
+
const decisionMaker: SamplingDecisionMaker = opts.decisionMaker ?? 'parent-based';
|
|
96
|
+
const ruleByType = new Map<string, number>();
|
|
97
|
+
for (const rule of opts.rules ?? []) {
|
|
98
|
+
ruleByType.set(rule.type, clampRate(rule.rate));
|
|
99
|
+
}
|
|
100
|
+
const streamingRate = clampRate(opts.streaming?.eventSamplingRate ?? 1.0);
|
|
101
|
+
const includeChunks = opts.streaming?.includeChunkContent ?? 'none';
|
|
102
|
+
const random = opts.random ?? Math.random;
|
|
103
|
+
const maxPerSecond = opts.maxPerSecond;
|
|
104
|
+
const now = opts.now ?? Date.now;
|
|
105
|
+
let windowStart: number | undefined;
|
|
106
|
+
let windowCount = 0;
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
shouldSample(type, parentSampled): boolean {
|
|
110
|
+
if (decisionMaker === 'always-on') return true;
|
|
111
|
+
if (decisionMaker === 'parent-based') {
|
|
112
|
+
// RP-19: true parent-based - follow the parent's real sampling
|
|
113
|
+
// decision. A child of an unsampled parent is NOT recorded (it would
|
|
114
|
+
// otherwise be an orphan); a root span (no parent) falls through to
|
|
115
|
+
// the rate.
|
|
116
|
+
//
|
|
117
|
+
// W-090: a per-type rule ACTS on children of a sampled parent -
|
|
118
|
+
// that is where the volume lives (`tool.execute` inside a sampled
|
|
119
|
+
// `agent.run` trace), and the docstring always promised it. A rule
|
|
120
|
+
// never resurrects children of an UNSAMPLED parent (orphans); a
|
|
121
|
+
// child dropped by its rule makes its own descendants inherit
|
|
122
|
+
// parentSampled=false.
|
|
123
|
+
if (parentSampled === true) {
|
|
124
|
+
const rule = ruleByType.get(type);
|
|
125
|
+
if (rule === undefined) return true;
|
|
126
|
+
if (rule >= 1) return true;
|
|
127
|
+
if (rule <= 0) return false;
|
|
128
|
+
return random() < rule;
|
|
129
|
+
}
|
|
130
|
+
if (parentSampled === false) return false;
|
|
131
|
+
}
|
|
132
|
+
if (decisionMaker === 'rate-limit') {
|
|
133
|
+
// RP-19: a real token-window limiter, distinct from the probabilistic
|
|
134
|
+
// path. Caps sampled spans to `maxPerSecond` per rolling second.
|
|
135
|
+
if (maxPerSecond === undefined) return true;
|
|
136
|
+
if (maxPerSecond <= 0) return false;
|
|
137
|
+
const t = now();
|
|
138
|
+
if (windowStart === undefined || t - windowStart >= 1000) {
|
|
139
|
+
windowStart = t;
|
|
140
|
+
windowCount = 0;
|
|
141
|
+
}
|
|
142
|
+
if (windowCount < maxPerSecond) {
|
|
143
|
+
windowCount += 1;
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
const rate = ruleByType.get(type) ?? baseRate;
|
|
149
|
+
if (rate >= 1) return true;
|
|
150
|
+
if (rate <= 0) return false;
|
|
151
|
+
return random() < rate;
|
|
152
|
+
},
|
|
153
|
+
shouldRecordEvent(name): boolean {
|
|
154
|
+
const rate = ruleByType.get(name) ?? streamingRate;
|
|
155
|
+
if (rate >= 1) return true;
|
|
156
|
+
if (rate <= 0) return false;
|
|
157
|
+
return random() < rate;
|
|
158
|
+
},
|
|
159
|
+
shouldIncludeChunkContent(): boolean {
|
|
160
|
+
return includeChunks !== 'none';
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function clampRate(rate: number): number {
|
|
166
|
+
if (Number.isNaN(rate)) return 1;
|
|
167
|
+
if (rate < 0) return 0;
|
|
168
|
+
if (rate > 1) return 1;
|
|
169
|
+
return rate;
|
|
170
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default human-readable span name per {@link SpanType}. Operators
|
|
3
|
+
* can override the name by passing `name` through a custom
|
|
4
|
+
* `setAttributes(...)` call after `startSpan(...)` returns.
|
|
5
|
+
*
|
|
6
|
+
* E8 (audit 2026-07-04): the OTel GenAI semantic conventions name spans
|
|
7
|
+
* `"{operation} {target}"` (`chat gpt-4.1`, `execute_tool get_weather`,
|
|
8
|
+
* `invoke_agent planner`) so trace UIs group like operations while
|
|
9
|
+
* keeping the target visible. When the span's initial attributes carry
|
|
10
|
+
* the target we emit that shape; otherwise the raw span type remains
|
|
11
|
+
* the stable fallback.
|
|
12
|
+
*
|
|
13
|
+
* @packageDocumentation
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type { SpanType } from '@graphorin/core';
|
|
17
|
+
|
|
18
|
+
function attrString(
|
|
19
|
+
attrs: Readonly<Record<string, unknown>> | undefined,
|
|
20
|
+
keys: readonly string[],
|
|
21
|
+
): string | undefined {
|
|
22
|
+
if (attrs === undefined) return undefined;
|
|
23
|
+
for (const key of keys) {
|
|
24
|
+
const value = attrs[key];
|
|
25
|
+
if (typeof value === 'string' && value.length > 0) return value;
|
|
26
|
+
}
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @stable
|
|
32
|
+
*/
|
|
33
|
+
export function spanNameFor(type: SpanType, attrs?: Readonly<Record<string, unknown>>): string {
|
|
34
|
+
switch (type) {
|
|
35
|
+
case 'provider.generate':
|
|
36
|
+
case 'provider.stream': {
|
|
37
|
+
const model = attrString(attrs, ['gen_ai.request.model', 'graphorin.provider.model']);
|
|
38
|
+
return model === undefined ? type : `chat ${model}`;
|
|
39
|
+
}
|
|
40
|
+
case 'tool.execute': {
|
|
41
|
+
const tool = attrString(attrs, ['gen_ai.tool.name', 'graphorin.tool.name']);
|
|
42
|
+
return tool === undefined ? type : `execute_tool ${tool}`;
|
|
43
|
+
}
|
|
44
|
+
case 'agent.run':
|
|
45
|
+
case 'agent.step': {
|
|
46
|
+
const agent = attrString(attrs, ['gen_ai.agent.name', 'gen_ai.agent.id']);
|
|
47
|
+
return agent === undefined ? type : `invoke_agent ${agent}`;
|
|
48
|
+
}
|
|
49
|
+
default:
|
|
50
|
+
return type;
|
|
51
|
+
}
|
|
52
|
+
}
|