@oxygen-agent/cli 1.894.0 → 1.906.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/README.md +1 -1
- package/dist/command-manifest.js +8 -3
- package/dist/index.js +199 -36
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +19 -1
- package/node_modules/@oxygen/shared/dist/copilot-plan.d.ts +137 -0
- package/node_modules/@oxygen/shared/dist/copilot-plan.js +435 -0
- package/node_modules/@oxygen/shared/dist/dnc-identities.d.ts +10 -0
- package/node_modules/@oxygen/shared/dist/dnc-identities.js +23 -0
- package/node_modules/@oxygen/shared/dist/egress-transport-readiness.d.ts +60 -0
- package/node_modules/@oxygen/shared/dist/egress-transport-readiness.js +67 -0
- package/node_modules/@oxygen/shared/dist/index.d.ts +3 -0
- package/node_modules/@oxygen/shared/dist/index.js +3 -0
- package/node_modules/@oxygen/shared/dist/langfuse.d.ts +57 -5
- package/node_modules/@oxygen/shared/dist/langfuse.js +243 -42
- package/node_modules/@oxygen/shared/dist/product-briefing-rules.d.ts +58 -0
- package/node_modules/@oxygen/shared/dist/product-briefing-rules.js +291 -0
- package/node_modules/@oxygen/shared/dist/product-doctrine.d.ts +11 -0
- package/node_modules/@oxygen/shared/dist/product-doctrine.js +70 -0
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +18 -11
- package/node_modules/@oxygen/shared/dist/sequences.js +47 -13
- package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.js +1 -1
- package/node_modules/@oxygen/shared/package.json +5 -0
- package/package.json +4 -2
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared/dedicated send-drain handoff timing. The worker and every status
|
|
3
|
+
* surface must evaluate the same readiness envelope: inventory existence alone
|
|
4
|
+
* is not proof that a dedicated process may claim sends yet.
|
|
5
|
+
*/
|
|
6
|
+
/** Longest a process may reuse one control-plane scope snapshot. */
|
|
7
|
+
export const EGRESS_SEND_SCOPE_CACHE_MAX_AGE_MS = 60_000;
|
|
8
|
+
/** Longest a tenant cycle may start provider sends under that snapshot. */
|
|
9
|
+
export const EGRESS_SEND_AUTHORITY_RETENTION_MS = 45_000;
|
|
10
|
+
/** Clock/scheduling margin between the old authority expiring and the new one starting. */
|
|
11
|
+
export const EGRESS_SEND_HANDOFF_MARGIN_MS = 5_000;
|
|
12
|
+
/**
|
|
13
|
+
* No new drain may become authoritative until every old cached scope AND every
|
|
14
|
+
* send cycle started on that cache's final millisecond have expired.
|
|
15
|
+
*/
|
|
16
|
+
export const DEDICATED_EGRESS_HANDOFF_DELAY_MS = EGRESS_SEND_SCOPE_CACHE_MAX_AGE_MS
|
|
17
|
+
+ EGRESS_SEND_AUTHORITY_RETENTION_MS
|
|
18
|
+
+ EGRESS_SEND_HANDOFF_MARGIN_MS;
|
|
19
|
+
export const DEDICATED_EGRESS_LIVENESS_MAX_AGE_MS = 30 * 60_000;
|
|
20
|
+
const FLY_DEDICATED_APP_PATTERN = /^oxygen-egress-(dev|prod)-[a-z0-9][a-z0-9-]*$/;
|
|
21
|
+
export function evaluateDedicatedEgressTransportReadiness(input, now = Date.now()) {
|
|
22
|
+
const createdAtMs = input.createdAt?.getTime() ?? Number.POSITIVE_INFINITY;
|
|
23
|
+
if (now - createdAtMs < DEDICATED_EGRESS_HANDOFF_DELAY_MS) {
|
|
24
|
+
return { ready: false, reason: "handoff_pending" };
|
|
25
|
+
}
|
|
26
|
+
const verifiedAtMs = input.vendorVerifiedAt?.getTime() ?? Number.NEGATIVE_INFINITY;
|
|
27
|
+
if (now - verifiedAtMs > DEDICATED_EGRESS_LIVENESS_MAX_AGE_MS) {
|
|
28
|
+
return { ready: false, reason: "vendor_verification_missing_or_stale" };
|
|
29
|
+
}
|
|
30
|
+
return { ready: true };
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The app name Fly assigns to this inventory row, or null when the row cannot
|
|
34
|
+
* authorize any dedicated process. Automated Fly purchase stores the app name
|
|
35
|
+
* as metadata.vendor_order_id because one app is exactly one vendor lease.
|
|
36
|
+
*/
|
|
37
|
+
export function readFlyDedicatedEgressAppName(input) {
|
|
38
|
+
const raw = input.metadata?.vendor_order_id;
|
|
39
|
+
if (typeof raw !== "string" || raw.length > 63)
|
|
40
|
+
return null;
|
|
41
|
+
const match = FLY_DEDICATED_APP_PATTERN.exec(raw);
|
|
42
|
+
return match?.[1] === input.environment ? raw : null;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Fly-specific readiness shared by the drain, status, and purchase replay.
|
|
46
|
+
* Timestamps cannot authorize an app by themselves: an orphan process for the
|
|
47
|
+
* same org must match the exact active row's immutable Fly app identity.
|
|
48
|
+
*/
|
|
49
|
+
export function evaluateFlyDedicatedEgressTransportReadiness(input, now = Date.now()) {
|
|
50
|
+
const appName = readFlyDedicatedEgressAppName(input);
|
|
51
|
+
if (!appName)
|
|
52
|
+
return { ready: false, reason: "dedicated_app_identity_missing" };
|
|
53
|
+
const readiness = evaluateDedicatedEgressTransportReadiness(input, now);
|
|
54
|
+
return readiness.ready ? { ready: true, appName } : readiness;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Retiring a drain is the inverse handoff: shared must remain excluded until
|
|
58
|
+
* every process that could have cached dedicated authority has expired it.
|
|
59
|
+
* Writers stamp this field with DB time while preserving the rest of metadata.
|
|
60
|
+
*/
|
|
61
|
+
export function dedicatedEgressRetirementStillCooling(metadata, now = Date.now()) {
|
|
62
|
+
const raw = metadata?.transport_retired_at;
|
|
63
|
+
if (typeof raw !== "string")
|
|
64
|
+
return false;
|
|
65
|
+
const retiredAt = Date.parse(raw);
|
|
66
|
+
return Number.isFinite(retiredAt) && now - retiredAt <= DEDICATED_EGRESS_HANDOFF_DELAY_MS;
|
|
67
|
+
}
|
|
@@ -24,6 +24,7 @@ export * from "./crm-activity-events.js";
|
|
|
24
24
|
export * from "./column-types.js";
|
|
25
25
|
export * from "./copilot-errors.js";
|
|
26
26
|
export * from "./copilot-journeys.js";
|
|
27
|
+
export * from "./copilot-plan.js";
|
|
27
28
|
export * from "./credit-guidance.js";
|
|
28
29
|
export * from "./directory.js";
|
|
29
30
|
export * from "./email-tracking-token.js";
|
|
@@ -49,6 +50,7 @@ export * from "./member-columns.js";
|
|
|
49
50
|
export * from "./microsoft-consent-url.js";
|
|
50
51
|
export * from "./networks.js";
|
|
51
52
|
export * from "./person-name.js";
|
|
53
|
+
export * from "./product-doctrine.js";
|
|
52
54
|
export * from "./recipes.js";
|
|
53
55
|
export * from "./sequence-template.js";
|
|
54
56
|
export * from "./sequence-crm-events.js";
|
|
@@ -75,6 +77,7 @@ export * from "./postgres-text.js";
|
|
|
75
77
|
export * from "./tags.js";
|
|
76
78
|
export * from "./telemetry.js";
|
|
77
79
|
export * from "./tenant-database-secret.js";
|
|
80
|
+
export * from "./egress-transport-readiness.js";
|
|
78
81
|
export * from "./timing.js";
|
|
79
82
|
export * from "./type-guards.js";
|
|
80
83
|
export * from "./worker-failures-queue.js";
|
|
@@ -24,6 +24,7 @@ export * from "./crm-activity-events.js";
|
|
|
24
24
|
export * from "./column-types.js";
|
|
25
25
|
export * from "./copilot-errors.js";
|
|
26
26
|
export * from "./copilot-journeys.js";
|
|
27
|
+
export * from "./copilot-plan.js";
|
|
27
28
|
export * from "./credit-guidance.js";
|
|
28
29
|
export * from "./directory.js";
|
|
29
30
|
export * from "./email-tracking-token.js";
|
|
@@ -49,6 +50,7 @@ export * from "./member-columns.js";
|
|
|
49
50
|
export * from "./microsoft-consent-url.js";
|
|
50
51
|
export * from "./networks.js";
|
|
51
52
|
export * from "./person-name.js";
|
|
53
|
+
export * from "./product-doctrine.js";
|
|
52
54
|
export * from "./recipes.js";
|
|
53
55
|
export * from "./sequence-template.js";
|
|
54
56
|
export * from "./sequence-crm-events.js";
|
|
@@ -86,6 +88,7 @@ export * from "./postgres-text.js";
|
|
|
86
88
|
export * from "./tags.js";
|
|
87
89
|
export * from "./telemetry.js";
|
|
88
90
|
export * from "./tenant-database-secret.js";
|
|
91
|
+
export * from "./egress-transport-readiness.js";
|
|
89
92
|
export * from "./timing.js";
|
|
90
93
|
export * from "./type-guards.js";
|
|
91
94
|
export * from "./worker-failures-queue.js";
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { Langfuse } from "langfuse";
|
|
2
1
|
type EnvMap = Record<string, string | undefined>;
|
|
3
2
|
export type LlmObservationLevel = "DEBUG" | "DEFAULT" | "WARNING" | "ERROR";
|
|
4
3
|
export type LlmTraceBody = {
|
|
@@ -10,6 +9,13 @@ export type LlmTraceBody = {
|
|
|
10
9
|
output?: unknown;
|
|
11
10
|
metadata?: Record<string, unknown>;
|
|
12
11
|
tags?: string[];
|
|
12
|
+
/**
|
|
13
|
+
* Root-observation span. v3 emitted a durationless trace object; v5's root IS
|
|
14
|
+
* an observation, so a root emitted once at completion should carry the real
|
|
15
|
+
* turn/run window. Defaults to "now" for both when omitted.
|
|
16
|
+
*/
|
|
17
|
+
startTime?: Date;
|
|
18
|
+
endTime?: Date;
|
|
13
19
|
};
|
|
14
20
|
export type LlmSpanBody = {
|
|
15
21
|
id: string;
|
|
@@ -22,6 +28,14 @@ export type LlmSpanBody = {
|
|
|
22
28
|
endTime?: Date;
|
|
23
29
|
level?: LlmObservationLevel;
|
|
24
30
|
statusMessage?: string | null;
|
|
31
|
+
/**
|
|
32
|
+
* v5 observations-first correlation. Pass the SAME sessionId/userId the trace
|
|
33
|
+
* carries: in v5 these live on every observation, and session-level cost only
|
|
34
|
+
* adds up if the cost-bearing generations carry the session too. Optional so
|
|
35
|
+
* an emitter that has no session still traces.
|
|
36
|
+
*/
|
|
37
|
+
sessionId?: string | null;
|
|
38
|
+
userId?: string | null;
|
|
25
39
|
};
|
|
26
40
|
export type LlmGenerationBody = LlmSpanBody & {
|
|
27
41
|
model?: string | null;
|
|
@@ -36,6 +50,9 @@ export type LlmEventBody = {
|
|
|
36
50
|
input?: unknown;
|
|
37
51
|
metadata?: Record<string, unknown>;
|
|
38
52
|
startTime?: Date;
|
|
53
|
+
/** See LlmSpanBody.sessionId. */
|
|
54
|
+
sessionId?: string | null;
|
|
55
|
+
userId?: string | null;
|
|
39
56
|
};
|
|
40
57
|
export type LlmTracingClient = {
|
|
41
58
|
trace(body: LlmTraceBody): void;
|
|
@@ -53,16 +70,51 @@ export type LlmTracingClient = {
|
|
|
53
70
|
*/
|
|
54
71
|
export declare function isLlmTracingEnabled(env?: EnvMap): boolean;
|
|
55
72
|
export declare function resolveLlmTracingEnvironment(env?: EnvMap): string;
|
|
56
|
-
|
|
57
|
-
|
|
73
|
+
/**
|
|
74
|
+
* Deterministic Langfuse trace id for an external seed (copilot turn id, agent
|
|
75
|
+
* run id, AI-column run id).
|
|
76
|
+
*
|
|
77
|
+
* Byte-identical to `createTraceId(seed)` from @langfuse/tracing — sha256 of the
|
|
78
|
+
* UTF-8 seed, first 32 hex chars — but SYNCHRONOUS. The official helper returns
|
|
79
|
+
* a Promise (it uses WebCrypto), and every cross-link site here writes the id on
|
|
80
|
+
* a hot, synchronous path: the tenant ledger's `turn_started` payload and the
|
|
81
|
+
* Axiom `copilot.turn.finished` rollup both carry `langfuse_trace_id`, and a
|
|
82
|
+
* tracer's `traceId` is read synchronously. langfuse.test.ts pins this against
|
|
83
|
+
* the SDK's own implementation so the two can never drift.
|
|
84
|
+
*/
|
|
85
|
+
export declare function llmTraceIdForSeed(seed: string): string;
|
|
86
|
+
export type LlmEmissionKind = "span" | "generation" | "event";
|
|
87
|
+
/** v5 correlating attributes, propagated onto the emitted observation. */
|
|
88
|
+
export type LlmCorrelation = {
|
|
89
|
+
traceName?: string;
|
|
90
|
+
userId?: string;
|
|
91
|
+
sessionId?: string;
|
|
92
|
+
tags?: string[];
|
|
93
|
+
};
|
|
94
|
+
export type LlmEmission = {
|
|
95
|
+
kind: LlmEmissionKind;
|
|
96
|
+
/** External seed (turn/run id) — hashed into the W3C trace id. */
|
|
97
|
+
traceSeed: string;
|
|
98
|
+
name: string;
|
|
99
|
+
/** Observation-level attributes, already compacted/bounded. */
|
|
100
|
+
attributes: Record<string, unknown>;
|
|
101
|
+
/** Correlating attributes applied via propagateAttributes(). */
|
|
102
|
+
correlation: LlmCorrelation;
|
|
103
|
+
startTime: Date;
|
|
104
|
+
endTime?: Date;
|
|
105
|
+
};
|
|
106
|
+
export type LlmEmitter = {
|
|
107
|
+
emit(emission: LlmEmission): void;
|
|
108
|
+
flush(): Promise<void>;
|
|
109
|
+
shutdown(): Promise<void>;
|
|
58
110
|
};
|
|
59
111
|
/**
|
|
60
112
|
* Construct a fail-open Langfuse client, or `null` when tracing is disabled or
|
|
61
113
|
* misconfigured. Prefer the process-wide `getLlmTracingClient` in app code;
|
|
62
|
-
* this direct factory exists for tests (inject `
|
|
114
|
+
* this direct factory exists for tests (inject `emitterImpl`).
|
|
63
115
|
*/
|
|
64
116
|
export declare function createLlmTracingClient(env?: EnvMap, options?: {
|
|
65
|
-
|
|
117
|
+
emitterImpl?: LlmEmitter;
|
|
66
118
|
}): LlmTracingClient | null;
|
|
67
119
|
export declare function getLlmTracingClient(env?: EnvMap): LlmTracingClient | null;
|
|
68
120
|
/** Flush the singleton if it exists. Never rejects. Hang off request/cycle ends. */
|
|
@@ -1,18 +1,40 @@
|
|
|
1
1
|
// LLM-observability transport (ADR 0014): Langfuse is the ONE sanctioned store
|
|
2
2
|
// for full prompt/completion/tool-IO payloads. Axiom stays metadata-only (log.ts
|
|
3
3
|
// redaction drops prompt/input/output-named fields BY DESIGN — that boundary is
|
|
4
|
-
// unchanged), and PostHog stays sanitized product analytics.
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
4
|
+
// unchanged), and PostHog stays sanitized product analytics.
|
|
5
|
+
//
|
|
6
|
+
// Langfuse SDK v5 is OpenTelemetry-based, so the v3-era "never touch OTel"
|
|
7
|
+
// isolation is now bought a different way: this module builds its OWN
|
|
8
|
+
// TracerProvider, hands it to Langfuse via setLangfuseTracerProvider(), and
|
|
9
|
+
// NEVER calls .register(). That matters — @vercel/otel (web) and NodeSDK
|
|
10
|
+
// (worker) install Axiom OTLP exporters on the GLOBAL provider, and every
|
|
11
|
+
// processor on a provider sees every span it creates. Registering globally
|
|
12
|
+
// would therefore ship prompts, completions and tool IO straight into
|
|
13
|
+
// oxygen-traces. The private provider is the load-bearing part of this file.
|
|
8
14
|
//
|
|
9
15
|
// Fail-open doctrine: tracing must never fail or stall product work. Every
|
|
10
16
|
// method swallows internally (worst case: one throttled metadata-only warn via
|
|
11
17
|
// log()), `flush()`/`shutdown()` never reject and are time-bounded, and a
|
|
12
|
-
// missing flag/key yields `null` (callers no-op).
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
18
|
+
// missing flag/key yields `null` (callers no-op).
|
|
19
|
+
//
|
|
20
|
+
// v5 semantics (CHANGED from v3):
|
|
21
|
+
// * Trace ids stay deterministic: sha256(seed) — same trace per turn/run, so
|
|
22
|
+
// lease-reclaim replays still converge onto ONE trace. See
|
|
23
|
+
// llmTraceIdForSeed.
|
|
24
|
+
// * Observation ids can NO LONGER be chosen. v5 observation ids are W3C span
|
|
25
|
+
// ids minted by OTel. The caller's stable id (`gen:<turn>:<n>`, `tool:…`)
|
|
26
|
+
// is preserved as metadata.oxygen_observation_id for correlation, but it no
|
|
27
|
+
// longer upserts: a replayed slice appends duplicate observations to the
|
|
28
|
+
// same trace instead of overwriting them.
|
|
29
|
+
// * v5 is observations-first: correlating attributes (userId, sessionId,
|
|
30
|
+
// tags) must ride EVERY observation, not just the root, or per-session cost
|
|
31
|
+
// rollups miss the cost-bearing generations. They are applied through
|
|
32
|
+
// propagateAttributes() around each emission — which is why the span/
|
|
33
|
+
// generation/event bodies carry sessionId/userId at all.
|
|
34
|
+
// * Trace-level input/output is deprecated in v5. Overall IO goes on the ROOT
|
|
35
|
+
// observation instead; setTraceIO()/setActiveTraceIO() are deliberately not
|
|
36
|
+
// used here.
|
|
37
|
+
import { createHash } from "node:crypto";
|
|
16
38
|
import { log } from "./log.js";
|
|
17
39
|
const FLUSH_TIMEOUT_MS = 5_000;
|
|
18
40
|
const WARN_THROTTLE_MS = 30_000;
|
|
@@ -56,6 +78,34 @@ export function resolveLlmTracingEnvironment(env = process.env) {
|
|
|
56
78
|
return flyEnv === "production" ? "production" : "development";
|
|
57
79
|
return "development";
|
|
58
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* Deterministic Langfuse trace id for an external seed (copilot turn id, agent
|
|
83
|
+
* run id, AI-column run id).
|
|
84
|
+
*
|
|
85
|
+
* Byte-identical to `createTraceId(seed)` from @langfuse/tracing — sha256 of the
|
|
86
|
+
* UTF-8 seed, first 32 hex chars — but SYNCHRONOUS. The official helper returns
|
|
87
|
+
* a Promise (it uses WebCrypto), and every cross-link site here writes the id on
|
|
88
|
+
* a hot, synchronous path: the tenant ledger's `turn_started` payload and the
|
|
89
|
+
* Axiom `copilot.turn.finished` rollup both carry `langfuse_trace_id`, and a
|
|
90
|
+
* tracer's `traceId` is read synchronously. langfuse.test.ts pins this against
|
|
91
|
+
* the SDK's own implementation so the two can never drift.
|
|
92
|
+
*/
|
|
93
|
+
export function llmTraceIdForSeed(seed) {
|
|
94
|
+
return createHash("sha256").update(seed, "utf8").digest("hex").slice(0, 32);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Deterministic synthetic parent span id for one trace.
|
|
98
|
+
*
|
|
99
|
+
* v5 does not let a caller choose observation ids, and the worker emits a single
|
|
100
|
+
* run's observations from different processes and slices — so there is no real
|
|
101
|
+
* root span id to nest under. Every observation of a run instead attaches to
|
|
102
|
+
* this stable pseudo-parent, which reproduces EXACTLY the flat shape the v3
|
|
103
|
+
* adapter already produced (v3 passed only `traceId`, never
|
|
104
|
+
* `parentObservationId`, so observations were already siblings of the trace).
|
|
105
|
+
*/
|
|
106
|
+
function rootSpanIdForSeed(seed) {
|
|
107
|
+
return createHash("sha256").update(`langfuse-root:${seed}`, "utf8").digest("hex").slice(0, 16);
|
|
108
|
+
}
|
|
59
109
|
// Bound one JSON-bearing field. Over the cap → an explicit truncation marker
|
|
60
110
|
// (never a silently clipped payload that parses as complete).
|
|
61
111
|
function boundJsonField(value) {
|
|
@@ -79,7 +129,7 @@ function boundJsonField(value) {
|
|
|
79
129
|
function compact(body) {
|
|
80
130
|
const out = {};
|
|
81
131
|
for (const [key, value] of Object.entries(body)) {
|
|
82
|
-
if (value === undefined)
|
|
132
|
+
if (value === undefined || value === null)
|
|
83
133
|
continue;
|
|
84
134
|
out[key] = key === "input" || key === "output" ? boundJsonField(value) : value;
|
|
85
135
|
}
|
|
@@ -97,13 +147,89 @@ function boundedNever(rejectable, warn) {
|
|
|
97
147
|
});
|
|
98
148
|
});
|
|
99
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* The real v4 emitter. Everything OTel is loaded LAZILY, on first emission, so
|
|
152
|
+
* a runtime with tracing disabled (the packed CLI, every test) never pays for
|
|
153
|
+
* the OTel tree — matching the "inert unless enabled" doctrine the flag already
|
|
154
|
+
* promises.
|
|
155
|
+
*/
|
|
156
|
+
function createOtelEmitter(env, warn) {
|
|
157
|
+
let handle = null;
|
|
158
|
+
const init = () => {
|
|
159
|
+
handle ??= (async () => {
|
|
160
|
+
try {
|
|
161
|
+
const [{ LangfuseSpanProcessor }, { BasicTracerProvider }, tracing] = await Promise.all([
|
|
162
|
+
import("@langfuse/otel"),
|
|
163
|
+
import("@opentelemetry/sdk-trace-base"),
|
|
164
|
+
import("@langfuse/tracing"),
|
|
165
|
+
]);
|
|
166
|
+
const processor = new LangfuseSpanProcessor({
|
|
167
|
+
publicKey: env.LANGFUSE_PUBLIC_KEY,
|
|
168
|
+
secretKey: env.LANGFUSE_SECRET_KEY,
|
|
169
|
+
...(env.LANGFUSE_BASE_URL?.trim() ? { baseUrl: env.LANGFUSE_BASE_URL.trim() } : {}),
|
|
170
|
+
environment: resolveLlmTracingEnvironment(env),
|
|
171
|
+
});
|
|
172
|
+
// PRIVATE provider. Deliberately NOT .register()ed — see the file
|
|
173
|
+
// header: the global provider carries the Axiom OTLP exporters, and a
|
|
174
|
+
// processor there would receive every prompt-bearing span.
|
|
175
|
+
//
|
|
176
|
+
// v5's smart default span filter needs no override here: this provider
|
|
177
|
+
// only ever creates spans through the Langfuse tracer, and
|
|
178
|
+
// `langfuse-sdk` spans are in the default allow-list.
|
|
179
|
+
const provider = new BasicTracerProvider({ spanProcessors: [processor] });
|
|
180
|
+
tracing.setLangfuseTracerProvider(provider);
|
|
181
|
+
return {
|
|
182
|
+
processor,
|
|
183
|
+
startObservation: tracing.startObservation,
|
|
184
|
+
propagateAttributes: tracing.propagateAttributes,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
warn(error, { stage: "init" });
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
})();
|
|
192
|
+
return handle;
|
|
193
|
+
};
|
|
194
|
+
return {
|
|
195
|
+
emit: (emission) => {
|
|
196
|
+
void init()
|
|
197
|
+
.then((h) => {
|
|
198
|
+
if (!h)
|
|
199
|
+
return;
|
|
200
|
+
const traceId = llmTraceIdForSeed(emission.traceSeed);
|
|
201
|
+
// propagateAttributes is scope-based in v5: the observation must be
|
|
202
|
+
// created INSIDE the callback to inherit userId/sessionId/tags.
|
|
203
|
+
h.propagateAttributes(emission.correlation, () => {
|
|
204
|
+
// The kind is a union, so no single overload matches it. Every
|
|
205
|
+
// overload returns an observation extending the same base, and the
|
|
206
|
+
// only method used here is .end() — so resolving against the span
|
|
207
|
+
// overload is safe while the real kind is passed at runtime.
|
|
208
|
+
const observation = h.startObservation(emission.name, emission.attributes, {
|
|
209
|
+
asType: emission.kind,
|
|
210
|
+
startTime: emission.startTime,
|
|
211
|
+
parentSpanContext: {
|
|
212
|
+
traceId,
|
|
213
|
+
spanId: rootSpanIdForSeed(emission.traceSeed),
|
|
214
|
+
traceFlags: 1,
|
|
215
|
+
},
|
|
216
|
+
});
|
|
217
|
+
observation.end(emission.endTime);
|
|
218
|
+
});
|
|
219
|
+
})
|
|
220
|
+
.catch((error) => warn(error, { stage: emission.kind }));
|
|
221
|
+
},
|
|
222
|
+
flush: () => init().then((h) => h?.processor.forceFlush() ?? Promise.resolve()),
|
|
223
|
+
shutdown: () => init().then((h) => h?.processor.shutdown() ?? Promise.resolve()),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
100
226
|
/**
|
|
101
227
|
* Construct a fail-open Langfuse client, or `null` when tracing is disabled or
|
|
102
228
|
* misconfigured. Prefer the process-wide `getLlmTracingClient` in app code;
|
|
103
|
-
* this direct factory exists for tests (inject `
|
|
229
|
+
* this direct factory exists for tests (inject `emitterImpl`).
|
|
104
230
|
*/
|
|
105
231
|
export function createLlmTracingClient(env = process.env, options) {
|
|
106
|
-
if (!options?.
|
|
232
|
+
if (!options?.emitterImpl && !isLlmTracingEnabled(env))
|
|
107
233
|
return null;
|
|
108
234
|
let lastWarnAtMs = 0;
|
|
109
235
|
const warn = (error, context) => {
|
|
@@ -117,30 +243,7 @@ export function createLlmTracingClient(env = process.env, options) {
|
|
|
117
243
|
...context,
|
|
118
244
|
});
|
|
119
245
|
};
|
|
120
|
-
|
|
121
|
-
try {
|
|
122
|
-
sdk =
|
|
123
|
-
options?.langfuseImpl ??
|
|
124
|
-
new Langfuse({
|
|
125
|
-
publicKey: env.LANGFUSE_PUBLIC_KEY,
|
|
126
|
-
secretKey: env.LANGFUSE_SECRET_KEY,
|
|
127
|
-
...(env.LANGFUSE_BASE_URL?.trim() ? { baseUrl: env.LANGFUSE_BASE_URL.trim() } : {}),
|
|
128
|
-
environment: resolveLlmTracingEnvironment(env),
|
|
129
|
-
sdkIntegration: "oxygen",
|
|
130
|
-
});
|
|
131
|
-
}
|
|
132
|
-
catch (error) {
|
|
133
|
-
warn(error, { stage: "construct" });
|
|
134
|
-
return null;
|
|
135
|
-
}
|
|
136
|
-
// The SDK surfaces async ingest failures on its emitter; unheard, they are
|
|
137
|
-
// unhandled rejections. Route them into the throttled warn.
|
|
138
|
-
try {
|
|
139
|
-
sdk.on?.("error", (error) => warn(error, { stage: "ingest" }));
|
|
140
|
-
}
|
|
141
|
-
catch {
|
|
142
|
-
// an emitter-less test double is fine
|
|
143
|
-
}
|
|
246
|
+
const emitter = options?.emitterImpl ?? createOtelEmitter(env, warn);
|
|
144
247
|
const guarded = (fn, stage) => {
|
|
145
248
|
try {
|
|
146
249
|
fn();
|
|
@@ -149,13 +252,110 @@ export function createLlmTracingClient(env = process.env, options) {
|
|
|
149
252
|
warn(error, { stage });
|
|
150
253
|
}
|
|
151
254
|
};
|
|
255
|
+
// propagateAttributes rejects non-string ids and anything over 200 chars, and
|
|
256
|
+
// drops the whole attribute with a warning rather than truncating. Normalize
|
|
257
|
+
// here so a stray null/oversized id degrades to "absent", never to a dropped
|
|
258
|
+
// correlation on every observation of the run.
|
|
259
|
+
const correlate = (input) => {
|
|
260
|
+
const bounded = (value) => {
|
|
261
|
+
if (typeof value !== "string")
|
|
262
|
+
return undefined;
|
|
263
|
+
const trimmed = value.trim();
|
|
264
|
+
if (trimmed === "" || trimmed.length > 200)
|
|
265
|
+
return undefined;
|
|
266
|
+
return trimmed;
|
|
267
|
+
};
|
|
268
|
+
return compact({
|
|
269
|
+
traceName: input.traceName,
|
|
270
|
+
sessionId: bounded(input.sessionId),
|
|
271
|
+
userId: bounded(input.userId),
|
|
272
|
+
tags: input.tags,
|
|
273
|
+
});
|
|
274
|
+
};
|
|
152
275
|
return {
|
|
153
|
-
trace: (body) => guarded(() =>
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
276
|
+
trace: (body) => guarded(() => {
|
|
277
|
+
const startTime = body.startTime ?? new Date();
|
|
278
|
+
emitter.emit({
|
|
279
|
+
kind: "span",
|
|
280
|
+
traceSeed: body.id,
|
|
281
|
+
name: body.name,
|
|
282
|
+
// Overall trace IO lives on this ROOT observation — v5 deprecates
|
|
283
|
+
// trace-level input/output, so it is deliberately not set separately.
|
|
284
|
+
attributes: compact({
|
|
285
|
+
input: body.input,
|
|
286
|
+
output: body.output,
|
|
287
|
+
metadata: { ...(body.metadata ?? {}), oxygen_observation_id: body.id },
|
|
288
|
+
}),
|
|
289
|
+
correlation: correlate({
|
|
290
|
+
traceName: body.name,
|
|
291
|
+
sessionId: body.sessionId,
|
|
292
|
+
userId: body.userId,
|
|
293
|
+
...(body.tags ? { tags: body.tags } : {}),
|
|
294
|
+
}),
|
|
295
|
+
startTime,
|
|
296
|
+
endTime: body.endTime ?? startTime,
|
|
297
|
+
});
|
|
298
|
+
}, "trace"),
|
|
299
|
+
span: (body) => guarded(() => {
|
|
300
|
+
emitter.emit({
|
|
301
|
+
kind: "span",
|
|
302
|
+
traceSeed: body.traceId,
|
|
303
|
+
name: body.name,
|
|
304
|
+
attributes: compact({
|
|
305
|
+
input: body.input,
|
|
306
|
+
output: body.output,
|
|
307
|
+
level: body.level,
|
|
308
|
+
statusMessage: body.statusMessage,
|
|
309
|
+
metadata: { ...(body.metadata ?? {}), oxygen_observation_id: body.id },
|
|
310
|
+
}),
|
|
311
|
+
correlation: correlate({ sessionId: body.sessionId, userId: body.userId }),
|
|
312
|
+
startTime: body.startTime ?? new Date(),
|
|
313
|
+
...(body.endTime ? { endTime: body.endTime } : {}),
|
|
314
|
+
});
|
|
315
|
+
}, "span"),
|
|
316
|
+
generation: (body) => guarded(() => {
|
|
317
|
+
emitter.emit({
|
|
318
|
+
kind: "generation",
|
|
319
|
+
traceSeed: body.traceId,
|
|
320
|
+
name: body.name,
|
|
321
|
+
attributes: compact({
|
|
322
|
+
input: body.input,
|
|
323
|
+
output: body.output,
|
|
324
|
+
level: body.level,
|
|
325
|
+
statusMessage: body.statusMessage,
|
|
326
|
+
model: body.model,
|
|
327
|
+
completionStartTime: body.completionStartTime,
|
|
328
|
+
usageDetails: body.usageDetails,
|
|
329
|
+
costDetails: body.costDetails,
|
|
330
|
+
metadata: { ...(body.metadata ?? {}), oxygen_observation_id: body.id },
|
|
331
|
+
}),
|
|
332
|
+
// The cost-bearing observation: v5 session cost only rolls up when
|
|
333
|
+
// the generation itself carries the session.
|
|
334
|
+
correlation: correlate({ sessionId: body.sessionId, userId: body.userId }),
|
|
335
|
+
startTime: body.startTime ?? new Date(),
|
|
336
|
+
...(body.endTime ? { endTime: body.endTime } : {}),
|
|
337
|
+
});
|
|
338
|
+
}, "generation"),
|
|
339
|
+
event: (body) => guarded(() => {
|
|
340
|
+
const startTime = body.startTime ?? new Date();
|
|
341
|
+
emitter.emit({
|
|
342
|
+
kind: "event",
|
|
343
|
+
traceSeed: body.traceId,
|
|
344
|
+
name: body.name,
|
|
345
|
+
attributes: compact({
|
|
346
|
+
input: body.input,
|
|
347
|
+
metadata: { ...(body.metadata ?? {}), oxygen_observation_id: body.id },
|
|
348
|
+
}),
|
|
349
|
+
correlation: correlate({ sessionId: body.sessionId, userId: body.userId }),
|
|
350
|
+
startTime,
|
|
351
|
+
endTime: startTime,
|
|
352
|
+
});
|
|
353
|
+
}, "event"),
|
|
354
|
+
// The "never rejects, bounded at ~5s" contract is the CLIENT's, so it is
|
|
355
|
+
// enforced here rather than inside one emitter — an emitter that throws
|
|
356
|
+
// synchronously or rejects must still not escape into product code.
|
|
357
|
+
flush: () => boundedNever((async () => emitter.flush())(), (error) => warn(error, { stage: "flush" })),
|
|
358
|
+
shutdown: () => boundedNever((async () => emitter.shutdown())(), (error) => warn(error, { stage: "shutdown" })),
|
|
159
359
|
};
|
|
160
360
|
}
|
|
161
361
|
// --- Process-wide singleton (both runtimes construct at most one client) ------
|
|
@@ -188,7 +388,8 @@ export function flushLlmTracing(env = process.env) {
|
|
|
188
388
|
// then call scheduleLlmTracingFlush() and the flush runs post-response instead
|
|
189
389
|
// of adding latency inside the request. Off-web (worker, tests) there is no
|
|
190
390
|
// scheduler and the flush degrades to fire-and-forget — the worker's cycle-end
|
|
191
|
-
// awaited flush +
|
|
391
|
+
// awaited flush + the processor's own interval flush are the durability
|
|
392
|
+
// guarantee there.
|
|
192
393
|
//
|
|
193
394
|
// The registration lives on globalThis, not in a module-local: Next.js gives
|
|
194
395
|
// instrumentation.ts and each route handler their own copy of this module, so a
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The briefing contract: every behavioural rule the OXYGEN session briefing has
|
|
3
|
+
* to state, and which half of it owns that rule.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS FILE EXISTS. The briefing is one string an agent reads once per
|
|
6
|
+
* session, so its only failure mode is silent omission: nothing crashes, no
|
|
7
|
+
* type breaks, and the rule simply stops being said. That happened. Splitting
|
|
8
|
+
* the MCP `instructions` into shared doctrine plus an MCP remainder dropped
|
|
9
|
+
* sixteen rules — sender rotation, the webhook/event/wait triggers, "never
|
|
10
|
+
* sends raw provider messages", the Knowledge citation and near-duplicate
|
|
11
|
+
* discipline, `ui://`, Crustdata routing, and more — while every existing
|
|
12
|
+
* assertion stayed green, because those assertions pinned lengths, the derived
|
|
13
|
+
* roster, and a handful of literal phrases, none of which is the rule set.
|
|
14
|
+
*
|
|
15
|
+
* WHY THIS SHAPE. There is no way to derive "this prose still tells an agent to
|
|
16
|
+
* rotate senders" from code, so the rule set has to be written down. What the
|
|
17
|
+
* ledger adds over sixteen inline `toContain` calls is:
|
|
18
|
+
*
|
|
19
|
+
* - Each entry names the behaviour in `rule`, so a future editor deleting a
|
|
20
|
+
* sentence sees what they are deleting rather than an opaque magic string.
|
|
21
|
+
* - `probes` is a disjunction: ANY match satisfies the rule. The briefing lives
|
|
22
|
+
* under a hard character budget, so it gets compressed often; probes key on
|
|
23
|
+
* the distinctive noun ("sender rotation") and accept alternate phrasings, so
|
|
24
|
+
* honest rewording stays green while deletion goes red.
|
|
25
|
+
* - `home` makes the shared/MCP split testable in both directions: a doctrine
|
|
26
|
+
* rule missing from the doctrine is red, an MCP-mechanics rule that leaks into
|
|
27
|
+
* the surface-neutral doctrine is red, and a rule stated in both halves is red
|
|
28
|
+
* (duplication is what the split was meant to end, and it is paid for twice in
|
|
29
|
+
* every session's context).
|
|
30
|
+
*
|
|
31
|
+
* WHAT IT DOES NOT CATCH, stated plainly so nobody over-trusts it:
|
|
32
|
+
*
|
|
33
|
+
* - Truth. A probe proves a phrase is present, not that the surrounding
|
|
34
|
+
* sentence is correct or still says the right thing.
|
|
35
|
+
* - Rules never entered here. New behaviour has to be added to this ledger by
|
|
36
|
+
* hand; the file is only as complete as its last review. `MINIMUM_RULE_COUNT`
|
|
37
|
+
* in the tests is a ratchet against quietly gutting the ledger itself, not a
|
|
38
|
+
* proof of completeness.
|
|
39
|
+
* - Whether an agent obeys any of it at runtime. That is an eval, not a test.
|
|
40
|
+
*/
|
|
41
|
+
/** Which half of the composed briefing must state a rule. */
|
|
42
|
+
export type BriefingHome = "doctrine" | "mcp";
|
|
43
|
+
export interface BriefingRule {
|
|
44
|
+
/** Stable identifier. Rename the prose, never this. */
|
|
45
|
+
id: string;
|
|
46
|
+
/** The behaviour in plain English: what an agent loses if this goes missing. */
|
|
47
|
+
rule: string;
|
|
48
|
+
/**
|
|
49
|
+
* `doctrine` — surface-neutral product truth, equally valid for a Copilot
|
|
50
|
+
* turn, an Agent run, and an MCP session; lives in `product-doctrine.ts`.
|
|
51
|
+
* `mcp` — MCP mechanics or literal `oxygen_*` tool names; lives in the MCP
|
|
52
|
+
* server's remainder, and must stay out of the doctrine.
|
|
53
|
+
*/
|
|
54
|
+
home: BriefingHome;
|
|
55
|
+
/** Accepted phrasings. Any one match satisfies the rule. */
|
|
56
|
+
probes: readonly RegExp[];
|
|
57
|
+
}
|
|
58
|
+
export declare const OXYGEN_BRIEFING_RULES: readonly BriefingRule[];
|