@dxos/observability 0.14.0 → 0.16.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/dist/lib/Observability.mjs +1 -248
- package/dist/lib/ai/AiObservability.mjs +1 -238
- package/dist/lib/chunk-AiObservability.mjs +238 -0
- package/dist/lib/chunk-AiObservability.mjs.map +1 -0
- package/dist/lib/chunk-Observability.mjs +248 -0
- package/dist/lib/chunk-Observability.mjs.map +1 -0
- package/dist/lib/chunk-ObservabilityClientProvider.mjs.map +1 -1
- package/dist/lib/chunk-ObservabilityExtension.mjs +38 -11
- package/dist/lib/chunk-ObservabilityExtension.mjs.map +1 -1
- package/dist/lib/chunk-ObservabilityProvider.mjs.map +1 -1
- package/dist/lib/chunk-OtelLogSink.mjs +105 -0
- package/dist/lib/{extensions/otel/OtelLogSink.mjs.map → chunk-OtelLogSink.mjs.map} +1 -1
- package/dist/lib/chunk-OtelMetricsSink.mjs +49 -0
- package/dist/lib/{extensions/otel/OtelMetricsSink.mjs.map → chunk-OtelMetricsSink.mjs.map} +1 -1
- package/dist/lib/chunk-OtelSpanSink.mjs.map +1 -1
- package/dist/lib/chunk-extensions.mjs.map +1 -1
- package/dist/lib/chunk-logs.mjs.map +1 -1
- package/dist/lib/chunk-support-logs.mjs +1 -1
- package/dist/lib/chunk-support-logs.mjs.map +1 -1
- package/dist/lib/extensions/otel/OtelLogSink.mjs +1 -105
- package/dist/lib/extensions/otel/OtelMetricsSink.mjs +1 -49
- package/dist/lib/extensions/otel/OtelSpanSink.mjs +1 -2
- package/dist/lib/index.mjs +4 -4
- package/dist/lib/posthog-transport.node.mjs.map +1 -1
- package/dist/lib/providers/ObservabilityClientProvider.mjs +1 -2
- package/dist/lib/storage.node.mjs.map +1 -1
- package/dist/lib/traces.mjs.map +1 -1
- package/dist/lib/traces.node.mjs.map +1 -1
- package/dist/types/src/ObservabilityExtension.d.ts +1 -1
- package/dist/types/src/ObservabilityExtension.d.ts.map +1 -1
- package/dist/types/src/ai/AiObservability.js +2 -2
- package/dist/types/src/ai/AiObservability.js.map +1 -1
- package/dist/types/src/ai/AiObservability.test.js +11 -11
- package/dist/types/src/ai/AiObservability.test.js.map +1 -1
- package/dist/types/src/extensions/otel/tail-sampling.test.js +1 -1
- package/dist/types/src/extensions/otel/tail-sampling.test.js.map +1 -1
- package/dist/types/src/extensions/posthog/before-send.d.ts +4 -0
- package/dist/types/src/extensions/posthog/before-send.d.ts.map +1 -0
- package/dist/types/src/extensions/posthog/before-send.js +19 -0
- package/dist/types/src/extensions/posthog/before-send.js.map +1 -0
- package/dist/types/src/extensions/posthog/before-send.test.d.ts +2 -0
- package/dist/types/src/extensions/posthog/before-send.test.d.ts.map +1 -0
- package/dist/types/src/extensions/posthog/before-send.test.js +30 -0
- package/dist/types/src/extensions/posthog/before-send.test.js.map +1 -0
- package/dist/types/src/extensions/posthog/extension.d.ts +10 -0
- package/dist/types/src/extensions/posthog/extension.d.ts.map +1 -1
- package/dist/types/src/extensions/posthog/extension.js +22 -2
- package/dist/types/src/extensions/posthog/extension.js.map +1 -1
- package/dist/types/src/version.d.ts +1 -1
- package/dist/types/src/version.js +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +20 -20
- package/src/ObservabilityExtension.ts +1 -1
- package/src/ai/AiObservability.test.ts +12 -11
- package/src/ai/AiObservability.ts +2 -2
- package/src/extensions/otel/tail-sampling.test.ts +1 -1
- package/src/extensions/posthog/before-send.test.ts +36 -0
- package/src/extensions/posthog/before-send.ts +26 -0
- package/src/extensions/posthog/extension.ts +30 -1
- package/src/version.ts +1 -1
- package/dist/lib/Observability.mjs.map +0 -1
- package/dist/lib/ai/AiObservability.mjs.map +0 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dxos/observability",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.0",
|
|
4
4
|
"description": "Provides a common interface for app and platform observability",
|
|
5
5
|
"homepage": "https://dxos.org",
|
|
6
6
|
"bugs": "https://github.com/dxos/dxos/issues",
|
|
@@ -109,30 +109,30 @@
|
|
|
109
109
|
"posthog-node": "^5.51.4",
|
|
110
110
|
"uuid": "^9.0.0",
|
|
111
111
|
"zone.js": "^0.15.0",
|
|
112
|
-
"@dxos/async": "0.
|
|
113
|
-
"@dxos/client": "0.
|
|
114
|
-
"@dxos/client-protocol": "0.
|
|
115
|
-
"@dxos/config": "0.
|
|
116
|
-
"@dxos/context": "0.
|
|
117
|
-
"@dxos/echo": "0.
|
|
118
|
-
"@dxos/effect": "0.
|
|
119
|
-
"@dxos/invariant": "0.
|
|
120
|
-
"@dxos/log": "0.
|
|
121
|
-
"@dxos/log-store-idb": "0.
|
|
122
|
-
"@dxos/node-std": "0.
|
|
123
|
-
"@dxos/protocols": "0.
|
|
124
|
-
"@dxos/tracing": "0.
|
|
125
|
-
"@dxos/util": "0.
|
|
112
|
+
"@dxos/async": "0.16.0",
|
|
113
|
+
"@dxos/client": "0.16.0",
|
|
114
|
+
"@dxos/client-protocol": "0.16.0",
|
|
115
|
+
"@dxos/config": "0.16.0",
|
|
116
|
+
"@dxos/context": "0.16.0",
|
|
117
|
+
"@dxos/echo": "0.16.0",
|
|
118
|
+
"@dxos/effect": "0.16.0",
|
|
119
|
+
"@dxos/invariant": "0.16.0",
|
|
120
|
+
"@dxos/log": "0.16.0",
|
|
121
|
+
"@dxos/log-store-idb": "0.16.0",
|
|
122
|
+
"@dxos/node-std": "0.16.0",
|
|
123
|
+
"@dxos/protocols": "0.16.0",
|
|
124
|
+
"@dxos/tracing": "0.16.0",
|
|
125
|
+
"@dxos/util": "0.16.0"
|
|
126
126
|
},
|
|
127
127
|
"devDependencies": {
|
|
128
128
|
"@types/js-yaml": "^4.0.5",
|
|
129
|
-
"@dxos/ai": "0.
|
|
130
|
-
"@dxos/client-services": "0.
|
|
131
|
-
"@dxos/errors": "0.
|
|
132
|
-
"@dxos/network-manager": "0.
|
|
129
|
+
"@dxos/ai": "0.16.0",
|
|
130
|
+
"@dxos/client-services": "0.16.0",
|
|
131
|
+
"@dxos/errors": "0.16.0",
|
|
132
|
+
"@dxos/network-manager": "0.16.0"
|
|
133
133
|
},
|
|
134
134
|
"peerDependencies": {
|
|
135
|
-
"effect": "4.0.
|
|
135
|
+
"effect": "4.0.4"
|
|
136
136
|
},
|
|
137
137
|
"publishConfig": {
|
|
138
138
|
"access": "public"
|
|
@@ -85,7 +85,7 @@ export type Inference = {
|
|
|
85
85
|
parentSpanId?: string;
|
|
86
86
|
/** Provider span name, e.g. `LanguageModel.streamText`. */
|
|
87
87
|
spanName: string;
|
|
88
|
-
/** `gen_ai.
|
|
88
|
+
/** `gen_ai.provider.name` — the serving product, not the wire dialect. */
|
|
89
89
|
provider?: string;
|
|
90
90
|
model?: string;
|
|
91
91
|
/** Conversation the call belongs to, when the call site named one. */
|
|
@@ -48,7 +48,7 @@ describe('AiSpanProcessor', () => {
|
|
|
48
48
|
tracer
|
|
49
49
|
.startSpan('LanguageModel.streamText', {
|
|
50
50
|
attributes: {
|
|
51
|
-
'gen_ai.
|
|
51
|
+
'gen_ai.provider.name': 'anthropic',
|
|
52
52
|
'gen_ai.request.model': 'claude-sonnet-5',
|
|
53
53
|
'gen_ai.request.temperature': 0.5,
|
|
54
54
|
'gen_ai.usage.input_tokens': 10,
|
|
@@ -81,7 +81,7 @@ describe('AiSpanProcessor', () => {
|
|
|
81
81
|
test('reduces errors to the exception class', async ({ expect }) => {
|
|
82
82
|
const { inferences, tracer } = await setup();
|
|
83
83
|
const span = tracer.startSpan('LanguageModel.generateText', {
|
|
84
|
-
attributes: { 'gen_ai.
|
|
84
|
+
attributes: { 'gen_ai.provider.name': 'anthropic' },
|
|
85
85
|
});
|
|
86
86
|
span.recordException(new TypeError('the user secret'));
|
|
87
87
|
span.setStatus({ code: SpanStatusCode.ERROR, message: 'the user secret' });
|
|
@@ -96,7 +96,7 @@ describe('AiSpanProcessor', () => {
|
|
|
96
96
|
tracer
|
|
97
97
|
.startSpan('LanguageModel.generateText', {
|
|
98
98
|
attributes: {
|
|
99
|
-
'gen_ai.
|
|
99
|
+
'gen_ai.provider.name': 'anthropic',
|
|
100
100
|
'spaceId': PLAINTEXT_SPACE,
|
|
101
101
|
'dxos.ai.input': JSON.stringify([{ role: 'user', content: 'hi' }]),
|
|
102
102
|
'dxos.ai.output': '[{"role":"assist',
|
|
@@ -113,7 +113,7 @@ describe('AiSpanProcessor', () => {
|
|
|
113
113
|
tracer
|
|
114
114
|
.startSpan('LanguageModel.generateText', {
|
|
115
115
|
attributes: {
|
|
116
|
-
'gen_ai.
|
|
116
|
+
'gen_ai.provider.name': 'anthropic',
|
|
117
117
|
'spaceId': PLAINTEXT_SPACE,
|
|
118
118
|
'dxos.ai.output': '[{"role":"assist',
|
|
119
119
|
'dxos.ai.truncated': true,
|
|
@@ -129,7 +129,7 @@ describe('AiSpanProcessor', () => {
|
|
|
129
129
|
tracer
|
|
130
130
|
.startSpan('LanguageModel.generateText', {
|
|
131
131
|
attributes: {
|
|
132
|
-
'gen_ai.
|
|
132
|
+
'gen_ai.provider.name': 'anthropic',
|
|
133
133
|
'gen_ai.usage.input_tokens': 3,
|
|
134
134
|
'spaceId': 'encrypted-space',
|
|
135
135
|
'dxos.ai.cache_read_tokens': 11,
|
|
@@ -147,7 +147,7 @@ describe('AiSpanProcessor', () => {
|
|
|
147
147
|
tracer
|
|
148
148
|
.startSpan('LanguageModel.generateText', {
|
|
149
149
|
attributes: {
|
|
150
|
-
'gen_ai.
|
|
150
|
+
'gen_ai.provider.name': 'anthropic',
|
|
151
151
|
'gen_ai.usage.input_tokens': 10,
|
|
152
152
|
'spaceId': 'encrypted-space',
|
|
153
153
|
'dxos.ai.input': JSON.stringify([{ role: 'user', content: 'private' }]),
|
|
@@ -173,7 +173,7 @@ describe('AiSpanProcessor', () => {
|
|
|
173
173
|
tracer
|
|
174
174
|
.startSpan('LanguageModel.generateText', {
|
|
175
175
|
attributes: {
|
|
176
|
-
'gen_ai.
|
|
176
|
+
'gen_ai.provider.name': 'anthropic',
|
|
177
177
|
'gen_ai.usage.input_tokens': 10,
|
|
178
178
|
'dxos.ai.input': JSON.stringify([{ role: 'user', content: 'private' }]),
|
|
179
179
|
},
|
|
@@ -189,7 +189,7 @@ describe('AiSpanProcessor', () => {
|
|
|
189
189
|
const { inferences, tracer } = await setup({ captureEnabled: () => false });
|
|
190
190
|
tracer
|
|
191
191
|
.startSpan('LanguageModel.generateText', {
|
|
192
|
-
attributes: { 'gen_ai.
|
|
192
|
+
attributes: { 'gen_ai.provider.name': 'anthropic', 'spaceId': PLAINTEXT_SPACE },
|
|
193
193
|
})
|
|
194
194
|
.end();
|
|
195
195
|
|
|
@@ -199,7 +199,8 @@ describe('AiSpanProcessor', () => {
|
|
|
199
199
|
test('reads the opt-in per span, so a mid-session toggle takes effect', async ({ expect }) => {
|
|
200
200
|
let enabled = false;
|
|
201
201
|
const { inferences, tracer } = await setup({ captureEnabled: () => enabled });
|
|
202
|
-
const emit = () =>
|
|
202
|
+
const emit = () =>
|
|
203
|
+
tracer.startSpan('LanguageModel.generateText', { attributes: { 'gen_ai.provider.name': 'a' } }).end();
|
|
203
204
|
|
|
204
205
|
emit();
|
|
205
206
|
expect(inferences).toHaveLength(0);
|
|
@@ -282,7 +283,7 @@ describe('AiSpanProcessor', () => {
|
|
|
282
283
|
],
|
|
283
284
|
});
|
|
284
285
|
const span = provider.getTracer('test').startSpan('LanguageModel.generateText', {
|
|
285
|
-
attributes: { 'gen_ai.
|
|
286
|
+
attributes: { 'gen_ai.provider.name': 'anthropic' },
|
|
286
287
|
});
|
|
287
288
|
|
|
288
289
|
expect(() => span.end()).not.toThrow();
|
|
@@ -353,7 +354,7 @@ type Captured = ObservabilityExtension.Inference;
|
|
|
353
354
|
const stubModel = LanguageModel.make({
|
|
354
355
|
generateText: ({ span }) =>
|
|
355
356
|
Effect.sync(() => {
|
|
356
|
-
Telemetry.addGenAIAnnotations(span, {
|
|
357
|
+
Telemetry.addGenAIAnnotations(span, { provider: { name: 'anthropic' }, request: { model: 'test-model' } });
|
|
357
358
|
return [
|
|
358
359
|
{ type: 'text' as const, text: 'hello' },
|
|
359
360
|
{
|
|
@@ -108,7 +108,7 @@ export type Options = {
|
|
|
108
108
|
const STREAM_SPAN_NAME = 'LanguageModel.streamText';
|
|
109
109
|
|
|
110
110
|
/** Marker attributes identifying a GenAI span (per OTel GenAI semantic conventions). */
|
|
111
|
-
const GEN_AI_MARKERS = ['gen_ai.
|
|
111
|
+
const GEN_AI_MARKERS = ['gen_ai.provider.name', 'gen_ai.request.model', 'gen_ai.response.model'];
|
|
112
112
|
|
|
113
113
|
/**
|
|
114
114
|
* Session/content attributes stamped by `AiTelemetry.ATTRIBUTES` in `@dxos/ai` (not part of the
|
|
@@ -215,7 +215,7 @@ export class AiSpanProcessor implements SpanProcessor {
|
|
|
215
215
|
spanId: spanContext.spanId,
|
|
216
216
|
parentSpanId: span.parentSpanContext?.spanId,
|
|
217
217
|
spanName: span.name,
|
|
218
|
-
provider: stringAttribute(attributes['gen_ai.
|
|
218
|
+
provider: stringAttribute(attributes['gen_ai.provider.name']),
|
|
219
219
|
model: stringAttribute(attributes['gen_ai.response.model'] ?? attributes['gen_ai.request.model']),
|
|
220
220
|
sessionId: stringAttribute(attributes[SESSION_ID_ATTR]),
|
|
221
221
|
parameters: modelParameters(attributes),
|
|
@@ -38,7 +38,7 @@ describe('TailSampler', () => {
|
|
|
38
38
|
|
|
39
39
|
test('keeps a model call the ratio would have dropped', ({ expect }) => {
|
|
40
40
|
const sampler = new TailSampler();
|
|
41
|
-
expect(sampler.keep(span({ attributes: { 'gen_ai.
|
|
41
|
+
expect(sampler.keep(span({ attributes: { 'gen_ai.provider.name': 'anthropic' } }))).toEqual(true);
|
|
42
42
|
});
|
|
43
43
|
|
|
44
44
|
test('keeps a turn or tool-call span the ratio would have dropped', ({ expect }) => {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type CaptureResult } from 'posthog-js';
|
|
6
|
+
import { describe, expect, test } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { dropBenignExceptions } from './before-send.ts';
|
|
9
|
+
|
|
10
|
+
const exception = (...values: string[]): CaptureResult => ({
|
|
11
|
+
uuid: '00000000-0000-0000-0000-000000000000',
|
|
12
|
+
event: '$exception',
|
|
13
|
+
properties: { $exception_list: values.map((value) => ({ type: 'Error', value })) },
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('dropBenignExceptions', () => {
|
|
17
|
+
test('drops a ResizeObserver loop error', () => {
|
|
18
|
+
expect(dropBenignExceptions(exception('ResizeObserver loop completed with undelivered notifications.'))).toBeNull();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test('keeps other exceptions, alone or alongside a benign one', () => {
|
|
22
|
+
const real = exception('Cannot read properties of undefined');
|
|
23
|
+
expect(dropBenignExceptions(real)).toBe(real);
|
|
24
|
+
const mixed = exception('ResizeObserver loop completed with undelivered notifications.', 'Boom');
|
|
25
|
+
expect(dropBenignExceptions(mixed)).toBe(mixed);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('keeps non-exception events', () => {
|
|
29
|
+
const pageview: CaptureResult = {
|
|
30
|
+
uuid: '00000000-0000-0000-0000-000000000000',
|
|
31
|
+
event: '$pageview',
|
|
32
|
+
properties: {},
|
|
33
|
+
};
|
|
34
|
+
expect(dropBenignExceptions(pageview)).toBe(pageview);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Copyright 2026 DXOS.org
|
|
3
|
+
//
|
|
4
|
+
|
|
5
|
+
import { type BeforeSendFn } from 'posthog-js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Browser-raised errors with no stack and no effect on the user: a ResizeObserver callback that resized an element
|
|
9
|
+
* mid-delivery is reported this way, and the browser delivers the skipped notifications on the next frame.
|
|
10
|
+
*/
|
|
11
|
+
const BENIGN_EXCEPTIONS = [/^ResizeObserver loop/];
|
|
12
|
+
|
|
13
|
+
/** Drops `$exception` events whose every exception is benign. */
|
|
14
|
+
export const dropBenignExceptions: BeforeSendFn = (capture) => {
|
|
15
|
+
if (capture?.event !== '$exception') {
|
|
16
|
+
return capture;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const exceptions: { value?: unknown }[] = capture.properties.$exception_list ?? [];
|
|
20
|
+
const benign =
|
|
21
|
+
exceptions.length > 0 &&
|
|
22
|
+
exceptions.every(
|
|
23
|
+
({ value }) => typeof value === 'string' && BENIGN_EXCEPTIONS.some((pattern) => pattern.test(value)),
|
|
24
|
+
);
|
|
25
|
+
return benign ? null : capture;
|
|
26
|
+
};
|
|
@@ -13,6 +13,7 @@ import { gzip, isNode } from '@dxos/util';
|
|
|
13
13
|
import * as ObservabilityExtension from '../../ObservabilityExtension.ts';
|
|
14
14
|
import { DXOS_VERSION } from '../../version.ts';
|
|
15
15
|
import { stubExtension } from '../stub.ts';
|
|
16
|
+
import { dropBenignExceptions } from './before-send.ts';
|
|
16
17
|
import {
|
|
17
18
|
AI_GENERATION_EVENT,
|
|
18
19
|
AI_SPAN_EVENT,
|
|
@@ -65,6 +66,11 @@ export type ExtensionsOptions = {
|
|
|
65
66
|
* pass the absolute URL of a deployment that does.
|
|
66
67
|
*/
|
|
67
68
|
feedbackLogsEndpoint?: string;
|
|
69
|
+
/**
|
|
70
|
+
* Holds session recording back until this settles, so the recorder's DOM snapshot and compression
|
|
71
|
+
* stay off the main thread during app startup. Ignored when `posthog.disable_session_recording` is set.
|
|
72
|
+
*/
|
|
73
|
+
sessionRecordingStart?: Effect.Effect<void>;
|
|
68
74
|
/** What the `posthog-node` transport needs; a browser host has posthog-js and reads none of it. */
|
|
69
75
|
node?: NodeOptions;
|
|
70
76
|
};
|
|
@@ -109,6 +115,12 @@ const uploadLogs = async (
|
|
|
109
115
|
}
|
|
110
116
|
};
|
|
111
117
|
|
|
118
|
+
/**
|
|
119
|
+
* Class on an iframe that session replay records with its content. Only for a same-origin iframe the
|
|
120
|
+
* recorder can read: it never releases an opaque one removed with an ancestor, which replays empty anyway.
|
|
121
|
+
*/
|
|
122
|
+
export const REPLAY_IFRAME_CLASS = 'dx-replay-iframe';
|
|
123
|
+
|
|
112
124
|
/** Create a PostHog-backed observability extension for events, errors, and support tickets. */
|
|
113
125
|
export const extensions: (options: ExtensionsOptions) => Effect.Effect<ObservabilityExtension.Extension> = Effect.fn(
|
|
114
126
|
function* ({
|
|
@@ -120,6 +132,7 @@ export const extensions: (options: ExtensionsOptions) => Effect.Effect<Observabi
|
|
|
120
132
|
logStore,
|
|
121
133
|
feedbackLogMaxSize,
|
|
122
134
|
feedbackLogsEndpoint = DEFAULT_FEEDBACK_LOGS_ENDPOINT,
|
|
135
|
+
sessionRecordingStart,
|
|
123
136
|
node,
|
|
124
137
|
}) {
|
|
125
138
|
if (isNode()) {
|
|
@@ -141,10 +154,11 @@ export const extensions: (options: ExtensionsOptions) => Effect.Effect<Observabi
|
|
|
141
154
|
const { default: posthog } = yield* Effect.promise(() => import('posthog-js'));
|
|
142
155
|
const { logProcessor } = yield* Effect.promise(() => import('./log-processor.ts'));
|
|
143
156
|
let unregisterPosthogProcessors: (() => void) | undefined;
|
|
157
|
+
const recordingStart = posthogConfig?.disable_session_recording ? undefined : sessionRecordingStart;
|
|
144
158
|
|
|
145
159
|
return {
|
|
146
160
|
initialize: (context) =>
|
|
147
|
-
Effect.
|
|
161
|
+
Effect.gen(function* () {
|
|
148
162
|
// https://posthog.com/docs/libraries/js/config
|
|
149
163
|
posthog.init(apiKey, {
|
|
150
164
|
api_host,
|
|
@@ -153,7 +167,22 @@ export const extensions: (options: ExtensionsOptions) => Effect.Effect<Observabi
|
|
|
153
167
|
// Cookies stay scoped to the exact host; the cross-subdomain dmn_chk_* probe fails on public-suffix hosts (e.g. *.pages.dev) and spams the console.
|
|
154
168
|
cross_subdomain_cookie: false,
|
|
155
169
|
...posthogConfig,
|
|
170
|
+
session_recording: {
|
|
171
|
+
// The recorder never releases a cross-origin iframe removed with an ancestor, and that
|
|
172
|
+
// iframe pins the whole detached tree; children of a blocked element are never recorded.
|
|
173
|
+
// `<body>` is exempt, or a hidden iframe appended to it would blank the whole replay.
|
|
174
|
+
blockSelector: `:not(body):has(> iframe:not(.${REPLAY_IFRAME_CLASS}))`,
|
|
175
|
+
...posthogConfig?.session_recording,
|
|
176
|
+
},
|
|
177
|
+
before_send: [dropBenignExceptions, ...[posthogConfig?.before_send ?? []].flat()],
|
|
178
|
+
...(recordingStart ? { disable_session_recording: true } : {}),
|
|
156
179
|
});
|
|
180
|
+
if (recordingStart) {
|
|
181
|
+
// Without an override this only lifts the local block; remote replay settings and sampling still apply.
|
|
182
|
+
yield* Effect.forkDetach(
|
|
183
|
+
recordingStart.pipe(Effect.andThen(Effect.sync(() => posthog.startSessionRecording()))),
|
|
184
|
+
);
|
|
185
|
+
}
|
|
157
186
|
posthog.register({
|
|
158
187
|
sdkVersion: DXOS_VERSION,
|
|
159
188
|
...(release ? { release } : {}),
|
package/src/version.ts
CHANGED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"Observability.mjs","names":[],"sources":["../../src/Observability.ts"],"sourcesContent":["//\n// Copyright 2025 DXOS.org\n//\n\n// @import-as-namespace\n\nimport * as Array from 'effect/Array';\nimport * as Effect from 'effect/Effect';\nimport * as Function from 'effect/Function';\n\nimport { type CleanupFn, SubscriptionList } from '@dxos/async';\nimport { invariant } from '@dxos/invariant';\nimport { log } from '@dxos/log';\n\nimport * as ObservabilityExtension from './ObservabilityExtension.ts';\n\nexport * from './storage/index.ts';\n\nconst attachAiCapture = async (observability: Observability): Promise<CleanupFn> => {\n const { AiSpanProcessor, contentCaptureAllowed } = await import('./ai/AiObservability.ts');\n const { Otel } = await import('./extensions/index.ts');\n return Otel.addSpanProcessor(\n new AiSpanProcessor({\n captureInference: (inference) => observability.ai.captureInference(inference),\n captureTurn: (turn) => observability.ai.captureTurn(turn),\n captureToolCall: (toolCall) => observability.ai.captureToolCall(toolCall),\n captureEnabled: () => observability.enabled,\n allowContent: contentCaptureAllowed,\n }),\n );\n};\n\n// TODO(wittjosiah): Figure out how to handle when telemetry is disabled.\n// In theory the setting should be both persisted and synchronized.\n// Initialize probably should still run for the cases where data is emitted manually (e.g., support tickets).\n\n/**\n * Provider of observability data.\n */\nexport type DataProvider = (observability: Observability) => Effect.Effect<CleanupFn | void, Error>;\n\n/**\n * Extensible observability provider.\n */\n// TODO(wittjosiah): Add pipe method.\nexport interface Observability {\n initialize(): Effect.Effect<void>;\n close(): Effect.Effect<void>;\n enable(): Effect.Effect<void>;\n disable(): Effect.Effect<void>;\n flush(): Effect.Effect<void>;\n addDataProvider(dataProvider: DataProvider): Effect.Effect<void, Error>;\n identify(\n distinctId: string,\n attributes?: ObservabilityExtension.Attributes,\n setOnceAttributes?: ObservabilityExtension.Attributes,\n ): void;\n alias(distinctId: string, previousId?: string): void;\n setTags(tags: ObservabilityExtension.Attributes, kind?: ObservabilityExtension.Kind): void;\n enabled: boolean;\n errors: ObservabilityExtension.Errors;\n events: ObservabilityExtension.Events;\n support: ObservabilityExtension.Support;\n ai: ObservabilityExtension.Ai;\n mcp: ObservabilityExtension.Mcp;\n /** True if at least one extension of the given kind reports as available. */\n isAvailable(kind: ObservabilityExtension.Kind): Effect.Effect<boolean>;\n metrics: ObservabilityExtension.Metrics;\n}\n\nclass ObservabilityImpl implements Observability {\n private _initialized = false;\n private readonly _extensions: ObservabilityExtension.Extension[] = [];\n private readonly _dataProviders: DataProvider[] = [];\n private readonly _subscriptions = new SubscriptionList();\n\n initialize(): Effect.Effect<void> {\n if (this._initialized) {\n return Effect.succeed(undefined);\n }\n\n const initializedExtensions: ObservabilityExtension.Extension[] = [];\n\n return Effect.gen({ self: this }, function* () {\n const context: ObservabilityExtension.ExtensionContext = {\n setTags: (tags, kind) => this.setTags(tags, kind),\n };\n for (const extension of this._extensions) {\n if (extension.initialize) {\n yield* extension.initialize(context);\n }\n initializedExtensions.push(extension);\n }\n\n const cleanups = yield* Effect.all(this._dataProviders.map((provider) => provider(this)));\n this._subscriptions.add(...cleanups.filter((cleanup) => cleanup !== undefined));\n this._subscriptions.add(yield* Effect.promise(() => attachAiCapture(this)));\n this._initialized = true;\n }).pipe(\n Effect.catch((error) =>\n Effect.gen({ self: this }, function* () {\n log.catch(error);\n // Roll back already-initialized extensions.\n for (const extension of initializedExtensions) {\n if (extension.close) {\n yield* extension.close().pipe(Effect.catch(() => Effect.succeed(undefined)));\n }\n }\n this._subscriptions.clear();\n }),\n ),\n );\n }\n\n close(): Effect.Effect<void> {\n return Effect.gen({ self: this }, function* () {\n this._subscriptions.clear();\n this._dataProviders.length = 0;\n for (const extension of this._extensions) {\n if (extension.close) {\n yield* extension.close();\n }\n }\n this._initialized = false;\n });\n }\n\n enable(): Effect.Effect<void> {\n return Effect.gen({ self: this }, function* () {\n for (const extension of this._extensions) {\n if (extension.enable) {\n yield* extension.enable();\n }\n }\n });\n }\n\n disable(): Effect.Effect<void> {\n return Effect.gen({ self: this }, function* () {\n for (const extension of this._extensions) {\n if (extension.disable) {\n yield* extension.disable();\n }\n }\n });\n }\n\n flush(): Effect.Effect<void> {\n return Effect.gen({ self: this }, function* () {\n for (const extension of this._extensions) {\n if (extension.flush) {\n yield* extension.flush();\n }\n }\n });\n }\n\n _addExtension(extension: ObservabilityExtension.Extension): void {\n invariant(!this._initialized, 'Observability is already initialized');\n this._extensions.push(extension);\n }\n\n _addDataProvider(dataProvider: DataProvider): void {\n invariant(!this._initialized, 'Observability is already initialized');\n this._dataProviders.push(dataProvider);\n }\n\n /**\n * Adds a data provider and initializes it.\n */\n addDataProvider(dataProvider: DataProvider): Effect.Effect<void, Error> {\n return Effect.gen({ self: this }, function* () {\n this._dataProviders.push(dataProvider);\n const cleanup = yield* dataProvider(this);\n if (cleanup) {\n this._subscriptions.add(cleanup);\n }\n });\n }\n\n identify(\n distinctId: string,\n attributes?: ObservabilityExtension.Attributes,\n setOnceAttributes?: ObservabilityExtension.Attributes,\n ): void {\n for (const extension of this._extensions) {\n extension.identify?.(distinctId, attributes, setOnceAttributes);\n }\n }\n\n alias(distinctId: string, previousId?: string): void {\n for (const extension of this._extensions) {\n extension.alias?.(distinctId, previousId);\n }\n }\n\n setTags(tags: ObservabilityExtension.Attributes, kind?: ObservabilityExtension.Kind): void {\n for (const extension of this._extensions) {\n if (kind && !extension.apis.some((api) => api.kind === kind)) {\n continue;\n }\n\n const processedTags = Object.fromEntries(\n Object.entries(tags)\n .filter((entry): entry is [string, string | number | boolean] => entry[1] !== undefined)\n .map(([key, value]) => [key, value.toString()]),\n );\n extension.setTags?.(processedTags, kind);\n }\n }\n\n get enabled(): boolean {\n return this._extensions.every((extension) => extension.enabled);\n }\n\n get errors(): ObservabilityExtension.Errors {\n return {\n captureException: (error, attributes) => {\n for (const extension of this._getExtensions('errors')) {\n extension.captureException(error, attributes);\n }\n },\n };\n }\n\n get events(): ObservabilityExtension.Events {\n return {\n captureEvent: (event, attributes) => {\n for (const extension of this._getExtensions('events')) {\n extension.captureEvent(event, attributes);\n }\n },\n };\n }\n\n get mcp(): ObservabilityExtension.Mcp {\n return {\n captureInitialize: (client) => {\n for (const extension of this._getExtensions('mcp')) {\n extension.captureInitialize(client);\n }\n },\n captureToolCall: (call) => {\n for (const extension of this._getExtensions('mcp')) {\n extension.captureToolCall(call);\n }\n },\n };\n }\n\n get ai(): ObservabilityExtension.Ai {\n return {\n captureInference: (inference) => {\n for (const extension of this._getExtensions('ai')) {\n extension.captureInference(inference);\n }\n },\n captureTurn: (turn) => {\n for (const extension of this._getExtensions('ai')) {\n extension.captureTurn(turn);\n }\n },\n captureToolCall: (toolCall) => {\n for (const extension of this._getExtensions('ai')) {\n extension.captureToolCall(toolCall);\n }\n },\n };\n }\n\n get support(): ObservabilityExtension.Support {\n return {\n uploadLogs: async () => {\n let key: string | undefined;\n for (const extension of this._getExtensions('support')) {\n key = (await extension.uploadLogs()) ?? key;\n }\n return key;\n },\n uploadNdjson: async (ndjson, kind) => {\n let key: string | undefined;\n for (const extension of this._getExtensions('support')) {\n key = (await extension.uploadNdjson(ndjson, kind)) ?? key;\n }\n return key;\n },\n sessionContext: () => {\n for (const extension of this._getExtensions('support')) {\n const context = extension.sessionContext();\n if (context) {\n return context;\n }\n }\n return undefined;\n },\n flushLogs: async (attributes) => {\n for (const extension of this._getExtensions('support')) {\n await extension.flushLogs(attributes);\n }\n },\n };\n }\n\n isAvailable(kind: ObservabilityExtension.Kind): Effect.Effect<boolean> {\n const apis = this._getExtensions(kind);\n if (apis.length === 0) {\n return Effect.succeed(false);\n }\n return Effect.gen({ self: this }, function* () {\n for (const api of apis) {\n const available = yield* api.isAvailable();\n if (available) {\n return true;\n }\n }\n return false;\n });\n }\n\n get metrics(): ObservabilityExtension.Metrics {\n return {\n gauge: (name, value, attributes, meta) => {\n for (const extension of this._getExtensions('metrics')) {\n extension.gauge(name, value, attributes, meta);\n }\n },\n increment: (name, value, attributes, meta) => {\n for (const extension of this._getExtensions('metrics')) {\n extension.increment(name, value, attributes, meta);\n }\n },\n distribution: (name, value, attributes, meta) => {\n for (const extension of this._getExtensions('metrics')) {\n extension.distribution(name, value, attributes, meta);\n }\n },\n observe: (name, callback, attributes, meta) => {\n const cleanups = this._getExtensions('metrics').map((extension) =>\n extension.observe(name, callback, attributes, meta),\n );\n return () => {\n for (const cleanup of cleanups) {\n cleanup();\n }\n };\n },\n };\n }\n\n private _getExtensions<T extends ObservabilityExtension.Kind>(\n kind: T,\n ): Extract<ObservabilityExtension.ExtensionApi, { kind: T }>[] {\n return Function.pipe(\n this._extensions,\n Array.flatMap((extension) => extension.apis),\n Array.filter((api): api is Extract<ObservabilityExtension.ExtensionApi, { kind: T }> => api.kind === kind),\n );\n }\n}\n\nexport const make = (): Effect.Effect<Observability> => Effect.succeed(new ObservabilityImpl());\n\nexport const addExtension = (_extension: Effect.Effect<ObservabilityExtension.Extension>) =>\n Effect.fn(function* (_observability: Effect.Effect<Observability>) {\n const observability = yield* _observability;\n const extension = yield* _extension;\n invariant('_addExtension' in observability && typeof observability._addExtension === 'function');\n observability._addExtension(extension);\n return observability;\n });\n\nexport const addDataProvider = (dataProvider: DataProvider) =>\n Effect.fn(function* (_observability: Effect.Effect<Observability>) {\n const observability = yield* _observability;\n invariant('_addDataProvider' in observability && typeof observability._addDataProvider === 'function');\n observability._addDataProvider(dataProvider);\n return observability;\n });\n\nexport const initialize = Effect.fn(function* (_observability: Effect.Effect<Observability>) {\n const observability = yield* _observability;\n yield* observability.initialize();\n return observability;\n});\n"],"mappings":"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"}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"AiObservability.mjs","names":[],"sources":["../../../src/ai/AiObservability.ts"],"sourcesContent":["//\n// Copyright 2026 DXOS.org\n//\n\n// @import-as-namespace\n\n// Consumers import `@dxos/observability/AiObservability`, so only the lazily-activated plugin module\n// that uses the AI sink loads the OpenTelemetry SDK.\n\nimport { type Context, SpanStatusCode } from '@opentelemetry/api';\nimport type { ReadableSpan, Span, SpanProcessor } from '@opentelemetry/sdk-trace-base';\n\nimport * as SpanAttributes from '@dxos/effect/SpanAttributes';\nimport { log } from '@dxos/log';\n\nimport type * as ObservabilityExtension from '../ObservabilityExtension.ts';\n\n/**\n * AI telemetry capture — data policy.\n *\n * The AI stack already annotates every model call with the OTel GenAI conventions (`gen_ai.*`,\n * emitted by the `effect/ai` provider layers) plus the `dxos.ai.*` attributes those\n * conventions have no room for. This reads the finished spans, applies the capture policy, and\n * hands each surviving call to a sink as a {@link ObservabilityExtension.Inference} — a shape\n * that follows the GenAI conventions, not any vendor's schema. Mapping onto a backend's own\n * vocabulary belongs to that backend's extension.\n *\n * It is the only place the policy is evaluated, so everything leaving passes through it:\n *\n * - **Nothing** unless {@link Options.captureEnabled} reports the user's telemetry opt-in as on.\n * The gate is checked here, before a record is built, rather than left to a backend client's own\n * opt-out flag: that flag is a separate store, so it is not the same answer as the user's\n * DXOS-side preference.\n * - **Metadata** — model, provider, request parameters, token counts (including the prompt-cache\n * reads and writes, which price the call and give the hit rate), latency, trace/span/session ids,\n * error class.\n * - **Content** — prompt, response and tool names, kept only for a span that names its space and\n * whose space {@link Options.allowContent} accepts. Content is always stamped on the span\n * upstream (`AiTelemetry` in `@dxos/ai`) and dropped here, rather than conditionally stamped, so\n * a source that forgets the policy cannot bypass it.\n *\n * Scrub rules enforced here regardless of the above:\n * - A span that does not name its space is treated as unknown, and unknown **denies** content. Only\n * a call that runs in a known space can have its content captured, so the policy fails closed\n * for one that does not (`AiSession` names it, and a process scoped to a space stamps it on every\n * span it runs; a utility model call outside both is reported as metadata only).\n * - Attribute **allowlist**: only the fields of `Inference` are read; every other span attribute\n * is dropped (so an accidental attribute upstream cannot leak through telemetry).\n * - Errors are reduced to the exception **class name** — `exception.message` and\n * `exception.stacktrace` recorded on the span are never forwarded, since provider error\n * messages can embed request or response fragments.\n *\n * Nothing here may throw: `onEnd` runs inside `Span.end()` on the fiber that made the model call,\n * and OTel's `MultiSpanProcessor` does not catch, so an escaping error would fail that call.\n */\n\n/**\n * AI telemetry capture policy:\n *\n * | Observability toggle | Space | Capture |\n * |----------------------|---------------------------------------|------------------|\n * | off | any | nothing |\n * | on | EDGE has plaintext access (all today) | metadata+content |\n * | on | E2E-encrypted (future) | metadata only |\n * | on | not declared by the call site | metadata only |\n *\n * Metadata is model, provider, tokens, latency, and trace/session ids. Content adds the prompt,\n * the response, and tool names — including tool results, i.e. data the agent read from the space.\n * The rationale: content that already leaves the device in plaintext for EDGE to replicate is not\n * newly exposed in kind by telemetry, whereas an E2E space promises that plaintext never reaches\n * infrastructure, and telemetry must not become the side channel that breaks it.\n *\n * The last row is the fail-closed default. A space id reaches the span only from a call site that\n * declares one (`AiSession` does; the utility model calls behind summarization, tagging, and\n * extraction do not), so content capture is opt-in per call site and an undeclared one reports\n * metadata only. That is what keeps this predicate honest once it stops returning true: it can\n * never be asked about a space nobody named.\n *\n * Both this predicate and the telemetry opt-in are evaluated in the sink, not at the model call, so\n * they apply to every event on the way out (see `AiSpanProcessor` in `@dxos/observability`, which\n * also holds the scrub rules).\n */\nexport const contentCaptureAllowed = (_spaceId: string): boolean => {\n // Always true today because every space replicates through EDGE in plaintext. This MUST NOT\n // stay unconditional: once E2E-encrypted spaces exist this predicate has to return false for\n // them — and apply at the data boundary, not just the conversation's home space, since a turn\n // that reads from an E2E space via a cross-space reference would otherwise leak its content.\n return true;\n};\n\nexport type Options = {\n /** Sink for a call that survived the policy — typically `Observability.ai`. */\n captureInference: (inference: ObservabilityExtension.Inference) => void;\n /** Sink for a conversation turn (`dxos.ai.kind = turn`), the unit its model calls group under. */\n captureTurn: (turn: ObservabilityExtension.Turn) => void;\n /** Sink for a tool call inside a turn (`dxos.ai.kind = tool`). */\n captureToolCall: (toolCall: ObservabilityExtension.ToolCall) => void;\n /** Whether the user's telemetry opt-in is on. Checked before an event is built. */\n captureEnabled: () => boolean;\n /**\n * Whether prompt/response content may leave for the given space. Only called for a span that\n * names one — a span with no space never reaches this predicate and never reports content.\n */\n allowContent: (spaceId: string) => boolean;\n};\n\n/** The span `effect/ai` names for a streamed call; anything else is a single response. */\nconst STREAM_SPAN_NAME = 'LanguageModel.streamText';\n\n/** Marker attributes identifying a GenAI span (per OTel GenAI semantic conventions). */\nconst GEN_AI_MARKERS = ['gen_ai.system', 'gen_ai.request.model', 'gen_ai.response.model'];\n\n/**\n * Session/content attributes stamped by `AiTelemetry.ATTRIBUTES` in `@dxos/ai` (not part of the\n * GenAI spec). Restated rather than imported because telemetry sits below the AI stack, not above\n * it; the `wired to @dxos/ai` cases in `AiObservability.test.ts` drive both halves so a rename fails there.\n */\nconst SESSION_ID_ATTR = SpanAttributes.AI.sessionId;\n/**\n * Shared with the process handle and ECHO rather than AI-specific: a model call inside a\n * space-scoped process carries it whether or not the call site named its space itself.\n */\nconst SPACE_ID_ATTR = SpanAttributes.SPACE_ID;\nconst INPUT_ATTR = SpanAttributes.AI.input;\nconst OUTPUT_ATTR = SpanAttributes.AI.output;\nconst TOOLS_ATTR = SpanAttributes.AI.tools;\nconst TRUNCATED_ATTR = SpanAttributes.AI.truncated;\nconst CACHE_READ_TOKENS_ATTR = SpanAttributes.AI.cacheReadTokens;\nconst CACHE_WRITE_TOKENS_ATTR = SpanAttributes.AI.cacheWriteTokens;\n/** What a non-model span is (`AiTelemetry.KIND`); a span with neither value is not AI at all. */\nconst KIND_ATTR = SpanAttributes.AI.kind;\nconst KIND_TURN = SpanAttributes.AI_KIND.turn;\nconst KIND_TOOL = SpanAttributes.AI_KIND.tool;\n/** Display name for a span whose OTel name is generic, e.g. the tool a `callTool` span ran. */\nconst NAME_ATTR = SpanAttributes.AI.name;\n\n/**\n * Reports finished AI spans to the injected sinks: model calls (`gen_ai.*`) as inferences, and the\n * turn and tool-call spans the AI stack marks with `dxos.ai.kind`. Every other span passes through\n * untouched.\n *\n * Attaches to the realm's tracer provider via `Otel.addSpanProcessor` rather than owning one: the\n * process manager's baseline tracer already records every span, so a second provider would mean a\n * second sampler and a second tracer over the same spans.\n */\nexport class AiSpanProcessor implements SpanProcessor {\n private readonly _captureInference: Options['captureInference'];\n private readonly _captureTurn: Options['captureTurn'];\n private readonly _captureToolCall: Options['captureToolCall'];\n private readonly _captureEnabled: Options['captureEnabled'];\n private readonly _allowContent: Options['allowContent'];\n\n constructor({ captureInference, captureTurn, captureToolCall, captureEnabled, allowContent }: Options) {\n this._captureInference = captureInference;\n this._captureTurn = captureTurn;\n this._captureToolCall = captureToolCall;\n this._captureEnabled = captureEnabled;\n this._allowContent = allowContent;\n }\n\n onStart(_span: Span, _parentContext: Context): void {}\n\n onEnd(span: ReadableSpan): void {\n try {\n this._capture(span);\n } catch (err) {\n // The caller is `Span.end()` on the model call's own fiber; failing telemetry must not fail it.\n log.catch(err, { span: span.name });\n }\n }\n\n private _capture(span: ReadableSpan): void {\n const attributes = span.attributes;\n const kind = attributes[KIND_ATTR];\n const isModelCall = GEN_AI_MARKERS.some((key) => attributes[key] !== undefined);\n if (kind !== KIND_TURN && kind !== KIND_TOOL && !isModelCall) {\n return;\n }\n if (!this._captureEnabled()) {\n return;\n }\n\n const spaceId = attributes[SPACE_ID_ATTR];\n const content = typeof spaceId === 'string' && this._allowContent(spaceId);\n\n if (kind === KIND_TURN || kind === KIND_TOOL) {\n const spanContext = span.spanContext();\n const record: ObservabilityExtension.AiSpanBase = {\n traceId: spanContext.traceId,\n spanId: spanContext.spanId,\n parentSpanId: span.parentSpanContext?.spanId,\n spanName: stringAttribute(attributes[NAME_ATTR]) ?? span.name,\n sessionId: stringAttribute(attributes[SESSION_ID_ATTR]),\n latency: hrTimeToSeconds(span.duration),\n content: content\n ? {\n input: parseJsonOrRaw(attributes[INPUT_ATTR]),\n output: parseJsonOrRaw(attributes[OUTPUT_ATTR]),\n truncated: attributes[TRUNCATED_ATTR] === true ? true : undefined,\n }\n : undefined,\n errorClass: span.status.code === SpanStatusCode.ERROR ? errorClass(span) : undefined,\n };\n if (kind === KIND_TURN) {\n this._captureTurn(record);\n } else {\n this._captureToolCall(record);\n }\n return;\n }\n\n const spanContext = span.spanContext();\n const inference: ObservabilityExtension.Inference = {\n traceId: spanContext.traceId,\n spanId: spanContext.spanId,\n parentSpanId: span.parentSpanContext?.spanId,\n spanName: span.name,\n provider: stringAttribute(attributes['gen_ai.system']),\n model: stringAttribute(attributes['gen_ai.response.model'] ?? attributes['gen_ai.request.model']),\n sessionId: stringAttribute(attributes[SESSION_ID_ATTR]),\n parameters: modelParameters(attributes),\n // The uncached count: add `cacheReadTokens` for what the model actually read.\n inputTokens: numberAttribute(attributes['gen_ai.usage.input_tokens']),\n outputTokens: numberAttribute(attributes['gen_ai.usage.output_tokens']),\n cacheReadTokens: numberAttribute(attributes[CACHE_READ_TOKENS_ATTR]),\n cacheWriteTokens: numberAttribute(attributes[CACHE_WRITE_TOKENS_ATTR]),\n latency: hrTimeToSeconds(span.duration),\n streaming: span.name === STREAM_SPAN_NAME,\n content: content\n ? {\n input: parseJsonOrRaw(attributes[INPUT_ATTR]),\n output: parseJsonOrRaw(attributes[OUTPUT_ATTR]),\n tools: parseJsonOrRaw(attributes[TOOLS_ATTR]),\n // A cut value no longer parses, so it is carried as the raw fragment; saying so keeps a\n // consumer from reading a string where an array was expected as a bug.\n truncated: attributes[TRUNCATED_ATTR] === true ? true : undefined,\n }\n : undefined,\n errorClass: span.status.code === SpanStatusCode.ERROR ? errorClass(span) : undefined,\n };\n\n this._captureInference(inference);\n }\n\n forceFlush(): Promise<void> {\n return Promise.resolve();\n }\n\n shutdown(): Promise<void> {\n return Promise.resolve();\n }\n}\n\nconst hrTimeToSeconds = ([seconds, nanos]: [number, number]): number => seconds + nanos / 1e9;\n\n// Span attributes are `unknown` to us; narrowing is what keeps a stray value out of the record.\nconst stringAttribute = (value: unknown): string | undefined => (typeof value === 'string' ? value : undefined);\nconst numberAttribute = (value: unknown): number | undefined => (typeof value === 'number' ? value : undefined);\n\nconst modelParameters = (attributes: ReadableSpan['attributes']): Record<string, unknown> | undefined => {\n const parameters = stripUndefined({\n temperature: attributes['gen_ai.request.temperature'],\n max_tokens: attributes['gen_ai.request.max_tokens'],\n top_p: attributes['gen_ai.request.top_p'],\n top_k: attributes['gen_ai.request.top_k'],\n });\n return Object.keys(parameters).length > 0 ? parameters : undefined;\n};\n\nconst parseJsonOrRaw = (value: unknown): unknown => {\n if (typeof value !== 'string') {\n return undefined;\n }\n try {\n return JSON.parse(value);\n } catch {\n // Truncated content attributes are expected to no longer parse; forward the raw string.\n return value;\n }\n};\n\n/** Only the exception class name — messages and stack traces can embed prompt/response content. */\nconst errorClass = (span: ReadableSpan): string => {\n for (const event of span.events) {\n const type = event.attributes?.['exception.type'];\n if (typeof type === 'string') {\n return type;\n }\n }\n return 'Error';\n};\n\nexport const stripUndefined = (properties: Record<string, unknown>): Record<string, unknown> =>\n Object.fromEntries(Object.entries(properties).filter(([, value]) => value !== undefined));\n"],"mappings":"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"}
|