@oxygen-agent/cli 1.1003.12 → 1.1010.1
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/column-decision-options.d.ts +20 -0
- package/dist/column-decision-options.js +54 -0
- package/dist/command-manifest.js +15 -2
- package/dist/functions-commands.js +11 -11
- package/dist/index.js +1222 -159
- package/dist/search-ai-filter-notice.d.ts +17 -0
- package/dist/search-ai-filter-notice.js +38 -0
- package/dist/skills.js +34 -10
- package/dist/util.d.ts +9 -0
- package/dist/util.js +14 -0
- package/node_modules/@oxygen/cli-ugc/dist/commands.js +296 -140
- package/node_modules/@oxygen/cli-ugc/dist/field-parser.d.ts +9 -0
- package/node_modules/@oxygen/cli-ugc/dist/field-parser.js +34 -0
- package/node_modules/@oxygen/recipe-sdk/dist/index.d.ts +13 -0
- package/node_modules/@oxygen/shared/dist/billing.d.ts +21 -0
- package/node_modules/@oxygen/shared/dist/billing.js +45 -0
- package/node_modules/@oxygen/shared/dist/byok-connect.js +5 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.d.ts +10 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +223 -13
- package/node_modules/@oxygen/shared/dist/cli-http-error.d.ts +8 -0
- package/node_modules/@oxygen/shared/dist/cli-http-error.js +8 -0
- package/node_modules/@oxygen/shared/dist/cli-result.js +1 -0
- package/node_modules/@oxygen/shared/dist/column-autofill.js +5 -23
- package/node_modules/@oxygen/shared/dist/column-decision.d.ts +50 -0
- package/node_modules/@oxygen/shared/dist/column-decision.js +228 -0
- package/node_modules/@oxygen/shared/dist/company-enrichment-fields.d.ts +9 -4
- package/node_modules/@oxygen/shared/dist/company-enrichment-fields.js +11 -8
- package/node_modules/@oxygen/shared/dist/copilot-skills.generated.d.ts +4 -4
- package/node_modules/@oxygen/shared/dist/copilot-skills.generated.js +4 -4
- package/node_modules/@oxygen/shared/dist/cutover-freeze.d.ts +26 -0
- package/node_modules/@oxygen/shared/dist/cutover-freeze.js +52 -0
- package/node_modules/@oxygen/shared/dist/data-suppliers.d.ts +57 -0
- package/node_modules/@oxygen/shared/dist/data-suppliers.js +59 -0
- package/node_modules/@oxygen/shared/dist/enrichment-intents.d.ts +6 -2
- package/node_modules/@oxygen/shared/dist/enrichment-intents.js +13 -23
- package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +60 -4
- package/node_modules/@oxygen/shared/dist/hosted-ai.js +125 -10
- package/node_modules/@oxygen/shared/dist/index.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/index.js +2 -0
- package/node_modules/@oxygen/shared/dist/langfuse.d.ts +44 -1
- package/node_modules/@oxygen/shared/dist/langfuse.js +407 -14
- package/node_modules/@oxygen/shared/dist/linkedin-countries.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/linkedin-countries.js +2 -0
- package/node_modules/@oxygen/shared/dist/linkedin-country-timezones.d.ts +24 -0
- package/node_modules/@oxygen/shared/dist/linkedin-country-timezones.js +276 -0
- package/node_modules/@oxygen/shared/dist/linkedin-message-deletion.d.ts +2 -0
- package/node_modules/@oxygen/shared/dist/linkedin-message-deletion.js +5 -0
- package/node_modules/@oxygen/shared/dist/linkedin-post-keywords.d.ts +44 -0
- package/node_modules/@oxygen/shared/dist/linkedin-post-keywords.js +116 -0
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.d.ts +96 -0
- package/node_modules/@oxygen/shared/dist/linkedin-sequences.js +123 -0
- package/node_modules/@oxygen/shared/dist/llm-durable-capture.d.ts +24 -0
- package/node_modules/@oxygen/shared/dist/llm-durable-capture.js +89 -0
- package/node_modules/@oxygen/shared/dist/llm-prompts.d.ts +75 -0
- package/node_modules/@oxygen/shared/dist/llm-prompts.js +161 -0
- package/node_modules/@oxygen/shared/dist/mailbox-egress-ownership.d.ts +90 -0
- package/node_modules/@oxygen/shared/dist/mailbox-egress-ownership.js +130 -0
- package/node_modules/@oxygen/shared/dist/operational-telemetry.d.ts +24 -0
- package/node_modules/@oxygen/shared/dist/operational-telemetry.js +73 -0
- package/node_modules/@oxygen/shared/dist/otlp-log-sink.d.ts +29 -4
- package/node_modules/@oxygen/shared/dist/otlp-log-sink.js +189 -36
- package/node_modules/@oxygen/shared/dist/product-analytics-events.d.ts +21 -2
- package/node_modules/@oxygen/shared/dist/product-analytics-events.js +21 -1
- package/node_modules/@oxygen/shared/dist/redaction.js +4 -1
- package/node_modules/@oxygen/shared/dist/scraper-lane-credential.d.ts +18 -0
- package/node_modules/@oxygen/shared/dist/scraper-lane-credential.js +23 -0
- package/node_modules/@oxygen/shared/dist/sequences.js +5 -1
- package/node_modules/@oxygen/shared/dist/signup-lead-payload.d.ts +80 -0
- package/node_modules/@oxygen/shared/dist/signup-lead-payload.js +198 -0
- package/node_modules/@oxygen/shared/dist/social-capabilities.d.ts +6 -0
- package/node_modules/@oxygen/shared/dist/social-capabilities.js +25 -16
- package/node_modules/@oxygen/shared/dist/social-post-metrics-core.d.ts +32 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-core.js +32 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-linkedin.d.ts +31 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-linkedin.js +103 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-series.d.ts +96 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-series.js +213 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-x.d.ts +13 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics-x.js +78 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics.d.ts +36 -0
- package/node_modules/@oxygen/shared/dist/social-post-metrics.js +51 -0
- package/node_modules/@oxygen/shared/dist/stripe-price-catalog.d.ts +36 -0
- package/node_modules/@oxygen/shared/dist/stripe-price-catalog.js +184 -0
- package/node_modules/@oxygen/shared/dist/stripe-subscription-kind.d.ts +41 -0
- package/node_modules/@oxygen/shared/dist/stripe-subscription-kind.js +44 -0
- package/node_modules/@oxygen/shared/dist/table-limits.d.ts +3 -0
- package/node_modules/@oxygen/shared/dist/table-limits.js +3 -0
- package/node_modules/@oxygen/shared/dist/telemetry-export-observer.d.ts +94 -0
- package/node_modules/@oxygen/shared/dist/telemetry-export-observer.js +298 -0
- package/node_modules/@oxygen/shared/dist/telemetry.d.ts +11 -0
- package/node_modules/@oxygen/shared/dist/telemetry.js +19 -1
- package/node_modules/@oxygen/shared/dist/ugc.d.ts +22 -11
- package/node_modules/@oxygen/shared/dist/ugc.js +10 -0
- package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -0
- package/node_modules/@oxygen/shared/dist/version.generated.js +2 -0
- package/node_modules/@oxygen/shared/dist/version.js +8 -1
- package/node_modules/@oxygen/shared/dist/workspace-event-catalog.js +0 -23
- package/node_modules/@oxygen/shared/dist/workspace-file-storage.d.ts +29 -0
- package/node_modules/@oxygen/shared/dist/workspace-file-storage.js +31 -0
- package/node_modules/@oxygen/shared/package.json +9 -0
- package/package.json +2 -1
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { LlmTraceBody, LlmTracingClient } from "./langfuse.js";
|
|
2
|
+
export type LlmCaptureTimestampLookup = {
|
|
3
|
+
type: "root";
|
|
4
|
+
id: string;
|
|
5
|
+
} | {
|
|
6
|
+
type: "event";
|
|
7
|
+
key: string;
|
|
8
|
+
} | {
|
|
9
|
+
type: "approval";
|
|
10
|
+
id: string;
|
|
11
|
+
phase: "requested" | "decided";
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Langfuse v3 deduplication includes the start date. Stable ids alone do not
|
|
15
|
+
* prevent cross-day duplicates. This per-trace adapter obtains the original
|
|
16
|
+
* ledger clock without blocking runtime methods or borrowing their transaction.
|
|
17
|
+
* The underlying transport still owns identity, payloads, isolation and flush.
|
|
18
|
+
*/
|
|
19
|
+
export declare function withDurableLlmCapture(input: {
|
|
20
|
+
client: LlmTracingClient;
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
root: LlmTraceBody;
|
|
23
|
+
readTimestamp?: ((lookup: LlmCaptureTimestampLookup) => Promise<Date | null>) | undefined;
|
|
24
|
+
}): LlmTracingClient;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { log } from "./log.js";
|
|
2
|
+
/**
|
|
3
|
+
* Langfuse v3 deduplication includes the start date. Stable ids alone do not
|
|
4
|
+
* prevent cross-day duplicates. This per-trace adapter obtains the original
|
|
5
|
+
* ledger clock without blocking runtime methods or borrowing their transaction.
|
|
6
|
+
* The underlying transport still owns identity, payloads, isolation and flush.
|
|
7
|
+
*/
|
|
8
|
+
export function withDurableLlmCapture(input) {
|
|
9
|
+
if (!input.enabled)
|
|
10
|
+
return input.client;
|
|
11
|
+
const pending = new Set();
|
|
12
|
+
const warned = new Set();
|
|
13
|
+
const missing = (stage) => {
|
|
14
|
+
if (warned.has(stage))
|
|
15
|
+
return;
|
|
16
|
+
warned.add(stage);
|
|
17
|
+
log("warn", "llm_tracing.missing_capture", { provider: "langfuse", stage, error_code: "langfuse_immutable_timestamp_unavailable" });
|
|
18
|
+
};
|
|
19
|
+
const valid = (value) => value instanceof Date && Number.isFinite(value.getTime());
|
|
20
|
+
const read = (lookup) => new Promise((resolve) => {
|
|
21
|
+
// Bounds injected readers too. A DB reader must independently bound its
|
|
22
|
+
// statement and release its own scope when a late acquisition completes.
|
|
23
|
+
const timer = setTimeout(() => resolve(null), 750);
|
|
24
|
+
timer.unref?.();
|
|
25
|
+
void Promise.resolve().then(() => input.readTimestamp?.(lookup) ?? null).then((date) => resolve(valid(date) ? date : null), () => resolve(null)).finally(() => clearTimeout(timer));
|
|
26
|
+
});
|
|
27
|
+
const seed = (startTime) => {
|
|
28
|
+
if (!valid(startTime)) {
|
|
29
|
+
missing("root_timestamp");
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
try {
|
|
33
|
+
input.client.trace({ ...input.root, startTime, endTime: startTime });
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
missing("root_emission");
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
return startTime;
|
|
40
|
+
};
|
|
41
|
+
// Seed synchronously when the caller already has the run/turn row. A web
|
|
42
|
+
// approval-only tracer can instead look up its root before queued children.
|
|
43
|
+
const knownRoot = valid(input.root.startTime) ? seed(input.root.startTime) : undefined;
|
|
44
|
+
const rootReady = knownRoot !== undefined ? Promise.resolve(knownRoot)
|
|
45
|
+
: read({ type: "root", id: input.root.id }).then(seed);
|
|
46
|
+
const enqueue = (action) => {
|
|
47
|
+
const task = rootReady.then(async (start) => { if (start)
|
|
48
|
+
await action(start); }).catch(() => missing("capture"));
|
|
49
|
+
pending.add(task);
|
|
50
|
+
void task.finally(() => pending.delete(task));
|
|
51
|
+
};
|
|
52
|
+
const eventLookup = (body) => {
|
|
53
|
+
const key = body.metadata?.oxygen_durable_event_key;
|
|
54
|
+
if (typeof key === "string" && key) {
|
|
55
|
+
// Auto-approval requests have a persisted approval card before an event
|
|
56
|
+
// append. The card clock is canonical for both approval capture paths.
|
|
57
|
+
const approvalKey = /^approval_(requested|decided):(.+)$/.exec(key);
|
|
58
|
+
if (approvalKey)
|
|
59
|
+
return { type: "approval", phase: approvalKey[1], id: approvalKey[2] };
|
|
60
|
+
return { type: "event", key };
|
|
61
|
+
}
|
|
62
|
+
const approval = /^apr:(requested|decided):(.+)$/.exec(body.id);
|
|
63
|
+
return approval ? { type: "approval", phase: approval[1], id: approval[2] } : null;
|
|
64
|
+
};
|
|
65
|
+
return {
|
|
66
|
+
trace: (body) => enqueue((startTime) => input.client.trace({ ...body, startTime })),
|
|
67
|
+
generation: (body) => enqueue(() => input.client.generation(body)),
|
|
68
|
+
embedding: (body) => enqueue(() => input.client.embedding(body)),
|
|
69
|
+
span: (body) => enqueue(() => input.client.span(body)),
|
|
70
|
+
event: (body) => {
|
|
71
|
+
const lookup = eventLookup(body);
|
|
72
|
+
enqueue(async () => {
|
|
73
|
+
if (!lookup) {
|
|
74
|
+
input.client.event(body);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const startTime = valid(body.startTime) ? body.startTime : await read(lookup);
|
|
78
|
+
if (!startTime) {
|
|
79
|
+
missing("event_timestamp");
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
input.client.event({ ...body, startTime });
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
score: (body) => input.client.score(body),
|
|
86
|
+
flush: async () => { await rootReady; await Promise.allSettled([...pending]); await input.client.flush(); },
|
|
87
|
+
shutdown: async () => { await rootReady; await Promise.allSettled([...pending]); await input.client.shutdown(); },
|
|
88
|
+
};
|
|
89
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export type LlmPromptRef = {
|
|
2
|
+
/** Langfuse prompt name, e.g. `copilot.system` or `table_column`. */
|
|
3
|
+
name: string;
|
|
4
|
+
/** First 12 hex chars of sha256(template); changes whenever the template does. */
|
|
5
|
+
hash: string;
|
|
6
|
+
/** True only for code-owned templates published by the prompt sync. */
|
|
7
|
+
registered: boolean;
|
|
8
|
+
};
|
|
9
|
+
export type LlmPromptDefinition = LlmPromptRef & {
|
|
10
|
+
registered: true;
|
|
11
|
+
template: string;
|
|
12
|
+
};
|
|
13
|
+
/** Deterministic content hash; object key order never changes it. */
|
|
14
|
+
export declare function llmPromptHash(template: unknown): string;
|
|
15
|
+
/** The Langfuse label that identifies one exact template version. */
|
|
16
|
+
export declare function llmPromptLabel(hash: string): string;
|
|
17
|
+
/**
|
|
18
|
+
* Declare a code-owned prompt template. Call once at module scope with the
|
|
19
|
+
* template the call site renders from. Placeholders stay literal (`{{name}}`)
|
|
20
|
+
* so the hash moves only when the instructions do, not per request.
|
|
21
|
+
*/
|
|
22
|
+
export declare function defineLlmPrompt(name: string, template: string): LlmPromptDefinition;
|
|
23
|
+
/** Names declared twice with different templates (a catalog defect). */
|
|
24
|
+
export declare function conflictingLlmPromptNames(): string[];
|
|
25
|
+
/** The Langfuse-safe name shape code prompts must use. */
|
|
26
|
+
export declare function isValidLlmPromptName(name: string): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* An AI column's prompt as the text Langfuse stores: the instruction template
|
|
29
|
+
* (placeholders unresolved) plus its output schema, so a schema change is a new
|
|
30
|
+
* prompt version too. Customer columns and code-owned helpers share it.
|
|
31
|
+
*/
|
|
32
|
+
export declare function aiColumnPromptTemplate(definition: {
|
|
33
|
+
prompt?: unknown;
|
|
34
|
+
outputSchema?: unknown;
|
|
35
|
+
}): string;
|
|
36
|
+
/** Declare a code-owned AI helper prompt (knowledge synthesis, drafts, ...). */
|
|
37
|
+
export declare function defineAiColumnPrompt(name: string, definition: {
|
|
38
|
+
prompt: string;
|
|
39
|
+
outputSchema?: Record<string, unknown> | null;
|
|
40
|
+
}): LlmPromptDefinition;
|
|
41
|
+
/**
|
|
42
|
+
* Resolve a lazy prompt getter on a product path. Prompt identity is telemetry:
|
|
43
|
+
* a template renderer that throws must degrade to an unlinked trace, never
|
|
44
|
+
* fail the model call it describes.
|
|
45
|
+
*/
|
|
46
|
+
export declare function safeLlmPrompt<T extends LlmPromptRef>(getter: () => T | null): T | null;
|
|
47
|
+
/** Reference a customer-authored or per-request template: metadata only. */
|
|
48
|
+
export declare function llmPromptRef(name: string, template: unknown): LlmPromptRef;
|
|
49
|
+
/**
|
|
50
|
+
* The registered code prompt whose template is exactly this text, if any: lets
|
|
51
|
+
* a tracer that only sees the rendered system message recognise a static
|
|
52
|
+
* template (compaction, sub-agent instructions) without importing its owner.
|
|
53
|
+
*/
|
|
54
|
+
export declare function findLlmPromptByTemplate(template: string): LlmPromptDefinition | undefined;
|
|
55
|
+
/**
|
|
56
|
+
* The declared code prompt behind a reference, only when its template still
|
|
57
|
+
* hashes to that reference: lets the tracing client publish the exact text a
|
|
58
|
+
* generation was rendered from, and never a same-named neighbour.
|
|
59
|
+
*/
|
|
60
|
+
export declare function registeredLlmPrompt(ref: LlmPromptRef): LlmPromptDefinition | undefined;
|
|
61
|
+
/** Every code prompt declared by the modules imported so far, sorted by name. */
|
|
62
|
+
export declare function listLlmPrompts(): LlmPromptDefinition[];
|
|
63
|
+
/**
|
|
64
|
+
* The prompt a Copilot/Agent model call ran on, from the exact messages sent.
|
|
65
|
+
* A main-loop call (`kind` "turn") uses the surface's versioned system
|
|
66
|
+
* template; compaction and sub-agent calls match a registered static template
|
|
67
|
+
* by the content of one of their system messages; anything else is traced by
|
|
68
|
+
* content hash only.
|
|
69
|
+
*/
|
|
70
|
+
export declare function llmPromptForModelCall(input: {
|
|
71
|
+
surface: "copilot" | "agent";
|
|
72
|
+
kind: string;
|
|
73
|
+
messages: unknown;
|
|
74
|
+
turnPrompt?: LlmPromptRef | null;
|
|
75
|
+
}): LlmPromptRef | null;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// Prompt identity for LLM tracing (ADR 0014). Prompts stay in code or in the
|
|
2
|
+
// tenant database; nothing here is fetched at runtime to BUILD a prompt, so a
|
|
3
|
+
// Langfuse outage can never leave a model call without its instructions.
|
|
4
|
+
//
|
|
5
|
+
// Every traced model call carries a prompt reference: a stable name plus a
|
|
6
|
+
// content hash of the template it was rendered from. Code-owned templates are
|
|
7
|
+
// declared once with `defineLlmPrompt` and are REGISTERED — the deploy-time sync
|
|
8
|
+
// (`scripts/ops/langfuse-prompts-sync.mjs`) publishes each one to Langfuse
|
|
9
|
+
// Prompt Management under the label `oxygen-<hash>`, which lets the tracing
|
|
10
|
+
// client link generations to the native prompt version. Customer-authored
|
|
11
|
+
// templates (table AI columns, Agent instructions, message templates) get an
|
|
12
|
+
// unregistered reference: name + hash as metadata only, never published.
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
const NAME_PATTERN = /^[a-z][a-z0-9_]*(\.[a-z0-9_]+)+$/;
|
|
15
|
+
const registry = new Map();
|
|
16
|
+
const conflicts = new Set();
|
|
17
|
+
function canonical(value) {
|
|
18
|
+
if (typeof value === "string")
|
|
19
|
+
return value;
|
|
20
|
+
if (Array.isArray(value))
|
|
21
|
+
return `[${value.map(canonical).join(",")}]`;
|
|
22
|
+
if (value && typeof value === "object") {
|
|
23
|
+
const entries = Object.entries(value)
|
|
24
|
+
.filter(([, entry]) => entry !== undefined)
|
|
25
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
26
|
+
return `{${entries.map(([key, entry]) => `${JSON.stringify(key)}:${typeof entry === "string" ? JSON.stringify(entry) : canonical(entry)}`).join(",")}}`;
|
|
27
|
+
}
|
|
28
|
+
return JSON.stringify(value ?? null);
|
|
29
|
+
}
|
|
30
|
+
/** Deterministic content hash; object key order never changes it. */
|
|
31
|
+
export function llmPromptHash(template) {
|
|
32
|
+
return createHash("sha256").update(canonical(template), "utf8").digest("hex").slice(0, 12);
|
|
33
|
+
}
|
|
34
|
+
/** The Langfuse label that identifies one exact template version. */
|
|
35
|
+
export function llmPromptLabel(hash) {
|
|
36
|
+
return `oxygen-${hash}`;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Declare a code-owned prompt template. Call once at module scope with the
|
|
40
|
+
* template the call site renders from. Placeholders stay literal (`{{name}}`)
|
|
41
|
+
* so the hash moves only when the instructions do, not per request.
|
|
42
|
+
*/
|
|
43
|
+
export function defineLlmPrompt(name, template) {
|
|
44
|
+
// Never throws: this runs at module scope in production code. The catalog
|
|
45
|
+
// test (llm-prompt-catalog.test.ts) enforces valid, unique, non-empty names.
|
|
46
|
+
const definition = { name, hash: llmPromptHash(template), registered: true, template };
|
|
47
|
+
const existing = registry.get(name);
|
|
48
|
+
if (existing && existing.hash !== definition.hash)
|
|
49
|
+
conflicts.add(name);
|
|
50
|
+
registry.set(name, definition);
|
|
51
|
+
return definition;
|
|
52
|
+
}
|
|
53
|
+
/** Names declared twice with different templates (a catalog defect). */
|
|
54
|
+
export function conflictingLlmPromptNames() {
|
|
55
|
+
return [...conflicts].sort();
|
|
56
|
+
}
|
|
57
|
+
/** The Langfuse-safe name shape code prompts must use. */
|
|
58
|
+
export function isValidLlmPromptName(name) {
|
|
59
|
+
return NAME_PATTERN.test(name);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* An AI column's prompt as the text Langfuse stores: the instruction template
|
|
63
|
+
* (placeholders unresolved) plus its output schema, so a schema change is a new
|
|
64
|
+
* prompt version too. Customer columns and code-owned helpers share it.
|
|
65
|
+
*/
|
|
66
|
+
export function aiColumnPromptTemplate(definition) {
|
|
67
|
+
const prompt = typeof definition.prompt === "string" ? definition.prompt : JSON.stringify(definition.prompt ?? null);
|
|
68
|
+
return definition.outputSchema == null
|
|
69
|
+
? prompt
|
|
70
|
+
: `${prompt}\n\nOUTPUT SCHEMA\n${JSON.stringify(definition.outputSchema, null, 2)}`;
|
|
71
|
+
}
|
|
72
|
+
/** Declare a code-owned AI helper prompt (knowledge synthesis, drafts, ...). */
|
|
73
|
+
export function defineAiColumnPrompt(name, definition) {
|
|
74
|
+
return defineLlmPrompt(name, aiColumnPromptTemplate(definition));
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Resolve a lazy prompt getter on a product path. Prompt identity is telemetry:
|
|
78
|
+
* a template renderer that throws must degrade to an unlinked trace, never
|
|
79
|
+
* fail the model call it describes.
|
|
80
|
+
*/
|
|
81
|
+
export function safeLlmPrompt(getter) {
|
|
82
|
+
try {
|
|
83
|
+
return getter();
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Reference a customer-authored or per-request template: metadata only. */
|
|
90
|
+
export function llmPromptRef(name, template) {
|
|
91
|
+
return { name, hash: llmPromptHash(template), registered: false };
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The registered code prompt whose template is exactly this text, if any: lets
|
|
95
|
+
* a tracer that only sees the rendered system message recognise a static
|
|
96
|
+
* template (compaction, sub-agent instructions) without importing its owner.
|
|
97
|
+
*/
|
|
98
|
+
export function findLlmPromptByTemplate(template) {
|
|
99
|
+
const hash = llmPromptHash(template);
|
|
100
|
+
for (const definition of registry.values())
|
|
101
|
+
if (definition.hash === hash)
|
|
102
|
+
return definition;
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The declared code prompt behind a reference, only when its template still
|
|
107
|
+
* hashes to that reference: lets the tracing client publish the exact text a
|
|
108
|
+
* generation was rendered from, and never a same-named neighbour.
|
|
109
|
+
*/
|
|
110
|
+
export function registeredLlmPrompt(ref) {
|
|
111
|
+
if (!ref.registered)
|
|
112
|
+
return undefined;
|
|
113
|
+
const definition = registry.get(ref.name);
|
|
114
|
+
return definition?.hash === ref.hash ? definition : undefined;
|
|
115
|
+
}
|
|
116
|
+
/** Every code prompt declared by the modules imported so far, sorted by name. */
|
|
117
|
+
export function listLlmPrompts() {
|
|
118
|
+
return [...registry.values()].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
119
|
+
}
|
|
120
|
+
function systemTexts(messages) {
|
|
121
|
+
if (!Array.isArray(messages))
|
|
122
|
+
return [];
|
|
123
|
+
const texts = [];
|
|
124
|
+
for (const message of messages) {
|
|
125
|
+
if (!message || typeof message !== "object" || message.role !== "system")
|
|
126
|
+
continue;
|
|
127
|
+
const content = message.content;
|
|
128
|
+
if (typeof content === "string")
|
|
129
|
+
texts.push(content);
|
|
130
|
+
else if (Array.isArray(content)) {
|
|
131
|
+
const text = content
|
|
132
|
+
.map((part) => (part && typeof part === "object" && typeof part.text === "string" ? part.text : ""))
|
|
133
|
+
.join("");
|
|
134
|
+
if (text)
|
|
135
|
+
texts.push(text);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return texts;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* The prompt a Copilot/Agent model call ran on, from the exact messages sent.
|
|
142
|
+
* A main-loop call (`kind` "turn") uses the surface's versioned system
|
|
143
|
+
* template; compaction and sub-agent calls match a registered static template
|
|
144
|
+
* by the content of one of their system messages; anything else is traced by
|
|
145
|
+
* content hash only.
|
|
146
|
+
*/
|
|
147
|
+
export function llmPromptForModelCall(input) {
|
|
148
|
+
if (input.kind === "turn" && input.turnPrompt)
|
|
149
|
+
return input.turnPrompt;
|
|
150
|
+
const texts = systemTexts(input.messages);
|
|
151
|
+
if (texts.length === 0)
|
|
152
|
+
return input.turnPrompt ?? null;
|
|
153
|
+
if (input.kind !== "turn") {
|
|
154
|
+
for (const text of texts) {
|
|
155
|
+
const registered = findLlmPromptByTemplate(text);
|
|
156
|
+
if (registered)
|
|
157
|
+
return registered;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return llmPromptRef(`${input.surface}.${input.kind === "turn" ? "system" : input.kind}`, texts.join("\n\n"));
|
|
161
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
export declare const MAILBOX_EGRESS_MODE_ENV = "OXYGEN_MAILBOX_EGRESS_MODE";
|
|
2
|
+
export type MailboxEgressMode = "direct" | "proxy";
|
|
3
|
+
/** `proxy` only when explicitly selected; anything else keeps today's transport. */
|
|
4
|
+
export declare function readMailboxEgressMode(env?: NodeJS.ProcessEnv): MailboxEgressMode;
|
|
5
|
+
export type DedicatedEgressInventoryRow = {
|
|
6
|
+
id: string;
|
|
7
|
+
organizationId: string | null;
|
|
8
|
+
status: string;
|
|
9
|
+
createdAt: Date | null;
|
|
10
|
+
vendorVerifiedAt: Date | null;
|
|
11
|
+
metadata: Record<string, unknown> | null;
|
|
12
|
+
};
|
|
13
|
+
export type DedicatedEgressReadinessInput = {
|
|
14
|
+
createdAt: Date | null;
|
|
15
|
+
environment: "dev" | "prod";
|
|
16
|
+
metadata: Record<string, unknown> | null;
|
|
17
|
+
vendorVerifiedAt: Date | null;
|
|
18
|
+
};
|
|
19
|
+
export type DedicatedEgressOwnershipSnapshot = {
|
|
20
|
+
activeOrganizationIds: ReadonlySet<string>;
|
|
21
|
+
/** Every recent retirement marker is re-evaluated on every read. */
|
|
22
|
+
retiredMetadataByOrganization: ReadonlyMap<string, readonly Record<string, unknown>[]>;
|
|
23
|
+
/** Present only when exactly one active, entitled row exists for the organization. */
|
|
24
|
+
readinessInputByOrganization: ReadonlyMap<string, DedicatedEgressReadinessInput>;
|
|
25
|
+
};
|
|
26
|
+
export type DedicatedEgressClassification = DedicatedEgressOwnershipSnapshot & {
|
|
27
|
+
/** Two active apps could race the same org through different IPs. */
|
|
28
|
+
conflicts: {
|
|
29
|
+
organizationId: string;
|
|
30
|
+
activeIds: string[];
|
|
31
|
+
}[];
|
|
32
|
+
/** Active inventory whose add-on entitlement is not live. */
|
|
33
|
+
entitlementMissing: {
|
|
34
|
+
organizationId: string;
|
|
35
|
+
activeId: string;
|
|
36
|
+
}[];
|
|
37
|
+
};
|
|
38
|
+
export type DedicatedEgressOwnership = {
|
|
39
|
+
/** Active or still-cooling dedicated inventory: never the shared IP. */
|
|
40
|
+
ownedOrganizationIds: ReadonlySet<string>;
|
|
41
|
+
/** Exact active and ready Fly app for each org; org identity alone is insufficient. */
|
|
42
|
+
readyAppNameByOrganization: ReadonlyMap<string, string>;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Classify `status in (active, retired)` dedicated Fly rows for one environment
|
|
46
|
+
* plus the orgs holding a live `dedicated_egress_ip` add-on. Entitlement is
|
|
47
|
+
* conservative ownership evidence: a paying org whose inventory row is missing
|
|
48
|
+
* still stays off the shared IP while its dedicated transport also blocks.
|
|
49
|
+
*/
|
|
50
|
+
export declare function classifyDedicatedEgressInventory(input: {
|
|
51
|
+
rows: readonly DedicatedEgressInventoryRow[];
|
|
52
|
+
entitledOrganizationIds: ReadonlySet<string>;
|
|
53
|
+
environment: "dev" | "prod";
|
|
54
|
+
now: number;
|
|
55
|
+
}): DedicatedEgressClassification;
|
|
56
|
+
/**
|
|
57
|
+
* Ownership at `now`. Re-evaluated on every read of a cached snapshot, because
|
|
58
|
+
* caching a readiness boolean would extend a liveness grant past its expiry.
|
|
59
|
+
*/
|
|
60
|
+
export declare function evaluateDedicatedEgressOwnership(snapshot: DedicatedEgressOwnershipSnapshot, now: number): DedicatedEgressOwnership;
|
|
61
|
+
export type MailboxEgressRoute =
|
|
62
|
+
/** Leave from this process's own address (direct mode only). */
|
|
63
|
+
{
|
|
64
|
+
kind: "direct";
|
|
65
|
+
}
|
|
66
|
+
/** Tunnel through the proxy Machine of this Fly app, which owns the IP. */
|
|
67
|
+
| {
|
|
68
|
+
kind: "proxy";
|
|
69
|
+
appName: string;
|
|
70
|
+
tier: "shared" | "dedicated";
|
|
71
|
+
}
|
|
72
|
+
/** No transport may carry this org right now. Fail closed: never fall back. */
|
|
73
|
+
| {
|
|
74
|
+
kind: "blocked";
|
|
75
|
+
reason: "dedicated_egress_not_ready" | "ownership_unreadable";
|
|
76
|
+
};
|
|
77
|
+
export declare const MAILBOX_EGRESS_SHARED_APP_ENV = "OXYGEN_MAILBOX_EGRESS_SHARED_APP";
|
|
78
|
+
/**
|
|
79
|
+
* The Fly app whose static egress IP carries every non-dedicated org. Dev sets
|
|
80
|
+
* OXYGEN_MAILBOX_EGRESS_SHARED_APP=oxygen-table-worker-prod and tunnels through
|
|
81
|
+
* the production shared proxy (founder decision 2026-09-25: Fly holds only
|
|
82
|
+
* production proxies). Web and worker read the same value, so they agree.
|
|
83
|
+
*/
|
|
84
|
+
export declare function sharedMailboxEgressAppName(environment: "dev" | "prod", env?: NodeJS.ProcessEnv): string;
|
|
85
|
+
/**
|
|
86
|
+
* The proxy-mode route for one org. A dedicated org uses its own app once ready
|
|
87
|
+
* and is blocked until then: routing it through the shared IP, even briefly,
|
|
88
|
+
* would show its mailboxes a second, shared source address.
|
|
89
|
+
*/
|
|
90
|
+
export declare function mailboxEgressRouteFor(organizationId: string, ownership: DedicatedEgressOwnership | null, environment: "dev" | "prod"): MailboxEgressRoute;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which static egress IP may carry an organization's mailbox traffic, as pure
|
|
3
|
+
* functions over control-plane rows. The worker (every org, cached) and the web
|
|
4
|
+
* app (one org, per request) must reach the same answer from the same rows, so
|
|
5
|
+
* the classification lives here rather than in either process.
|
|
6
|
+
*
|
|
7
|
+
* Two transports read it. In `direct` mode the process's own IP carries shared
|
|
8
|
+
* orgs and a dedicated org's own Fly drain carries it. In `proxy` mode every
|
|
9
|
+
* process tunnels through the one Fly proxy Machine that owns the right IP:
|
|
10
|
+
* `oxygen-table-worker-<env>` for shared orgs, the org's own dedicated app
|
|
11
|
+
* otherwise (ADR 0029 amendment 2026-09-25).
|
|
12
|
+
*/
|
|
13
|
+
import { dedicatedEgressRetirementStillCooling, evaluateFlyDedicatedEgressTransportReadiness, } from "./egress-transport-readiness.js";
|
|
14
|
+
export const MAILBOX_EGRESS_MODE_ENV = "OXYGEN_MAILBOX_EGRESS_MODE";
|
|
15
|
+
/** `proxy` only when explicitly selected; anything else keeps today's transport. */
|
|
16
|
+
export function readMailboxEgressMode(env = process.env) {
|
|
17
|
+
return env[MAILBOX_EGRESS_MODE_ENV]?.trim().toLowerCase() === "proxy" ? "proxy" : "direct";
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Classify `status in (active, retired)` dedicated Fly rows for one environment
|
|
21
|
+
* plus the orgs holding a live `dedicated_egress_ip` add-on. Entitlement is
|
|
22
|
+
* conservative ownership evidence: a paying org whose inventory row is missing
|
|
23
|
+
* still stays off the shared IP while its dedicated transport also blocks.
|
|
24
|
+
*/
|
|
25
|
+
export function classifyDedicatedEgressInventory(input) {
|
|
26
|
+
const rowsByOrganization = new Map();
|
|
27
|
+
for (const row of input.rows) {
|
|
28
|
+
if (!row.organizationId)
|
|
29
|
+
continue;
|
|
30
|
+
const current = rowsByOrganization.get(row.organizationId) ?? [];
|
|
31
|
+
current.push(row);
|
|
32
|
+
rowsByOrganization.set(row.organizationId, current);
|
|
33
|
+
}
|
|
34
|
+
const activeOrganizationIds = new Set(input.entitledOrganizationIds);
|
|
35
|
+
const retiredMetadataByOrganization = new Map();
|
|
36
|
+
const readinessInputByOrganization = new Map();
|
|
37
|
+
const conflicts = [];
|
|
38
|
+
const entitlementMissing = [];
|
|
39
|
+
for (const [organizationId, organizationRows] of rowsByOrganization) {
|
|
40
|
+
const activeRows = organizationRows.filter((row) => row.status === "active");
|
|
41
|
+
const coolingRetiredRows = organizationRows.filter((row) => row.status === "retired" && dedicatedEgressRetirementStillCooling(row.metadata, input.now));
|
|
42
|
+
// Shared must keep excluding the org, but no dedicated transport may carry it
|
|
43
|
+
// while two active apps could race its jobs through different source IPs. A
|
|
44
|
+
// just-retired row also keeps shared away for one handoff window.
|
|
45
|
+
if (activeRows.length > 0)
|
|
46
|
+
activeOrganizationIds.add(organizationId);
|
|
47
|
+
if (coolingRetiredRows.length > 0) {
|
|
48
|
+
// Preserve every marker: picking one unordered row could choose an older
|
|
49
|
+
// retirement and hand the org back while a newer app's cache is alive.
|
|
50
|
+
retiredMetadataByOrganization.set(organizationId, coolingRetiredRows.map((row) => row.metadata ?? {}));
|
|
51
|
+
}
|
|
52
|
+
if (activeRows.length !== 1) {
|
|
53
|
+
if (activeRows.length > 1) {
|
|
54
|
+
conflicts.push({ organizationId, activeIds: activeRows.map((row) => row.id) });
|
|
55
|
+
}
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const row = activeRows[0];
|
|
59
|
+
if (!input.entitledOrganizationIds.has(organizationId)) {
|
|
60
|
+
entitlementMissing.push({ organizationId, activeId: row.id });
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
readinessInputByOrganization.set(organizationId, {
|
|
64
|
+
createdAt: row.createdAt,
|
|
65
|
+
environment: input.environment,
|
|
66
|
+
metadata: row.metadata,
|
|
67
|
+
vendorVerifiedAt: row.vendorVerifiedAt,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
activeOrganizationIds,
|
|
72
|
+
retiredMetadataByOrganization,
|
|
73
|
+
readinessInputByOrganization,
|
|
74
|
+
conflicts,
|
|
75
|
+
entitlementMissing,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Ownership at `now`. Re-evaluated on every read of a cached snapshot, because
|
|
80
|
+
* caching a readiness boolean would extend a liveness grant past its expiry.
|
|
81
|
+
*/
|
|
82
|
+
export function evaluateDedicatedEgressOwnership(snapshot, now) {
|
|
83
|
+
const ownedOrganizationIds = new Set(snapshot.activeOrganizationIds);
|
|
84
|
+
for (const [organizationId, metadataRows] of snapshot.retiredMetadataByOrganization) {
|
|
85
|
+
if (metadataRows.some((metadata) => dedicatedEgressRetirementStillCooling(metadata, now))) {
|
|
86
|
+
ownedOrganizationIds.add(organizationId);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const readyAppNameByOrganization = new Map();
|
|
90
|
+
for (const [organizationId, readinessInput] of snapshot.readinessInputByOrganization) {
|
|
91
|
+
const readiness = evaluateFlyDedicatedEgressTransportReadiness(readinessInput, now);
|
|
92
|
+
if (readiness.ready)
|
|
93
|
+
readyAppNameByOrganization.set(organizationId, readiness.appName);
|
|
94
|
+
}
|
|
95
|
+
return { ownedOrganizationIds, readyAppNameByOrganization };
|
|
96
|
+
}
|
|
97
|
+
export const MAILBOX_EGRESS_SHARED_APP_ENV = "OXYGEN_MAILBOX_EGRESS_SHARED_APP";
|
|
98
|
+
const FLY_APP_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{1,62}$/;
|
|
99
|
+
/**
|
|
100
|
+
* The Fly app whose static egress IP carries every non-dedicated org. Dev sets
|
|
101
|
+
* OXYGEN_MAILBOX_EGRESS_SHARED_APP=oxygen-table-worker-prod and tunnels through
|
|
102
|
+
* the production shared proxy (founder decision 2026-09-25: Fly holds only
|
|
103
|
+
* production proxies). Web and worker read the same value, so they agree.
|
|
104
|
+
*/
|
|
105
|
+
export function sharedMailboxEgressAppName(environment, env = process.env) {
|
|
106
|
+
const override = env[MAILBOX_EGRESS_SHARED_APP_ENV]?.trim();
|
|
107
|
+
if (override) {
|
|
108
|
+
if (!FLY_APP_NAME_PATTERN.test(override)) {
|
|
109
|
+
throw new Error(`${MAILBOX_EGRESS_SHARED_APP_ENV} is not a Fly app name`);
|
|
110
|
+
}
|
|
111
|
+
return override;
|
|
112
|
+
}
|
|
113
|
+
return `oxygen-table-worker-${environment}`;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* The proxy-mode route for one org. A dedicated org uses its own app once ready
|
|
117
|
+
* and is blocked until then: routing it through the shared IP, even briefly,
|
|
118
|
+
* would show its mailboxes a second, shared source address.
|
|
119
|
+
*/
|
|
120
|
+
export function mailboxEgressRouteFor(organizationId, ownership, environment) {
|
|
121
|
+
if (!ownership)
|
|
122
|
+
return { kind: "blocked", reason: "ownership_unreadable" };
|
|
123
|
+
const dedicatedApp = ownership.readyAppNameByOrganization.get(organizationId);
|
|
124
|
+
if (dedicatedApp)
|
|
125
|
+
return { kind: "proxy", appName: dedicatedApp, tier: "dedicated" };
|
|
126
|
+
if (ownership.ownedOrganizationIds.has(organizationId)) {
|
|
127
|
+
return { kind: "blocked", reason: "dedicated_egress_not_ready" };
|
|
128
|
+
}
|
|
129
|
+
return { kind: "proxy", appName: sharedMailboxEgressAppName(environment), tier: "shared" };
|
|
130
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
type AttributeContainer = {
|
|
2
|
+
attributes?: Record<string, unknown>;
|
|
3
|
+
name?: string;
|
|
4
|
+
};
|
|
5
|
+
/** Sink-local copy; the managed logger retains its original record. */
|
|
6
|
+
export declare function operationalLogSnapshot(record: Record<string, unknown>): Record<string, unknown>;
|
|
7
|
+
/** Export a sanitized copy; never mutate the span another destination reads. */
|
|
8
|
+
export declare function operationalSpanSnapshot<T extends {
|
|
9
|
+
name: string;
|
|
10
|
+
attributes: Record<string, unknown>;
|
|
11
|
+
status: {
|
|
12
|
+
code: number;
|
|
13
|
+
message?: string;
|
|
14
|
+
};
|
|
15
|
+
events: ReadonlyArray<{
|
|
16
|
+
name: string;
|
|
17
|
+
attributes?: Record<string, unknown>;
|
|
18
|
+
}>;
|
|
19
|
+
links?: ReadonlyArray<AttributeContainer>;
|
|
20
|
+
resource?: AttributeContainer;
|
|
21
|
+
instrumentationScope?: AttributeContainer;
|
|
22
|
+
spanContext(): unknown;
|
|
23
|
+
}>(span: T): T;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { normalizeTelemetryAttributes, redactSecretsInString } from "./redaction.js";
|
|
2
|
+
// Namespaced semantic-convention fields evade the generic exact-key log policy.
|
|
3
|
+
// Numeric usage remains operational data, subject to the shared secret policy.
|
|
4
|
+
const PAYLOAD_KEY = /(?:^|[._-])(?:prompts?|completions?|inputs?|outputs?|payloads?|rows?|messages?|content|body|customer_data)(?:$|[._-])|^(?:request|response)$/i;
|
|
5
|
+
const EXCEPTION_CONTENT = /^exception\.(?:message|stacktrace)$/i;
|
|
6
|
+
const QUERY_CONTENT = /^(?:db\.(?:statement|query\.text)(?:[._].*)?|(?:http|url)\.(?:query|query_string|request\.query)|process\.command(?:_args|_line)?)$/i;
|
|
7
|
+
// Addresses keep their domain (the operational signal) and drop the person.
|
|
8
|
+
const EMAIL_ADDRESS = /[A-Za-z0-9._%+-]+@((?:[A-Za-z0-9-]+\.)+[A-Za-z]{2,})/g;
|
|
9
|
+
function maskEmails(value) {
|
|
10
|
+
return value.replace(EMAIL_ADDRESS, "***@$1");
|
|
11
|
+
}
|
|
12
|
+
function operationalText(value) {
|
|
13
|
+
return maskEmails(redactSecretsInString(value).replace(/(?:https?:\/\/|\/)[^\s"'<>]+/gi, stripOperationalUrl));
|
|
14
|
+
}
|
|
15
|
+
function operationalValue(value, key, depth = 0) {
|
|
16
|
+
if (PAYLOAD_KEY.test(key) && typeof value !== "number" && typeof value !== "boolean")
|
|
17
|
+
return "[omitted]";
|
|
18
|
+
if (typeof value === "string")
|
|
19
|
+
return /(?:^|[._-])(?:url|path|target|href)(?:$|[._-])/i.test(key)
|
|
20
|
+
? maskEmails(stripOperationalUrl(redactSecretsInString(value))) : operationalText(value);
|
|
21
|
+
if (!value || typeof value !== "object")
|
|
22
|
+
return value;
|
|
23
|
+
if (depth >= 5)
|
|
24
|
+
return "[truncated]";
|
|
25
|
+
if (value instanceof Date)
|
|
26
|
+
return value.toISOString();
|
|
27
|
+
if (Array.isArray(value))
|
|
28
|
+
return value.slice(0, 20).map((entry) => operationalValue(entry, key, depth + 1));
|
|
29
|
+
return Object.fromEntries(Object.entries(value).slice(0, 40)
|
|
30
|
+
.filter(([child]) => !EXCEPTION_CONTENT.test(child) && !QUERY_CONTENT.test(child))
|
|
31
|
+
.map(([child, entry]) => [child, operationalValue(entry, child, depth + 1)]));
|
|
32
|
+
}
|
|
33
|
+
function operationalAttributes(attributes) {
|
|
34
|
+
return normalizeTelemetryAttributes(Object.fromEntries(Object.entries(attributes ?? {}).slice(0, 40)
|
|
35
|
+
.filter(([key]) => !EXCEPTION_CONTENT.test(key) && !QUERY_CONTENT.test(key))
|
|
36
|
+
.map(([key, value]) => [key, operationalValue(value, key)])));
|
|
37
|
+
}
|
|
38
|
+
/** Sink-local copy; the managed logger retains its original record. */
|
|
39
|
+
export function operationalLogSnapshot(record) {
|
|
40
|
+
return operationalAttributes(record);
|
|
41
|
+
}
|
|
42
|
+
function containerSnapshot(container) {
|
|
43
|
+
return { ...container, attributes: operationalAttributes(container.attributes) };
|
|
44
|
+
}
|
|
45
|
+
/** Export a sanitized copy; never mutate the span another destination reads. */
|
|
46
|
+
export function operationalSpanSnapshot(span) {
|
|
47
|
+
const attributes = operationalAttributes(span.attributes);
|
|
48
|
+
// Exception messages/stacks can contain arbitrary customer payloads. Error
|
|
49
|
+
// classification lives on the span's structured attributes and correlated log.
|
|
50
|
+
return {
|
|
51
|
+
...span,
|
|
52
|
+
spanContext: () => span.spanContext(),
|
|
53
|
+
name: operationalText(span.name),
|
|
54
|
+
attributes,
|
|
55
|
+
status: { code: span.status.code },
|
|
56
|
+
events: span.events.map((event) => ({
|
|
57
|
+
...containerSnapshot(event),
|
|
58
|
+
name: operationalText(event.name),
|
|
59
|
+
})),
|
|
60
|
+
...(span.links ? { links: span.links.map(containerSnapshot) } : {}),
|
|
61
|
+
...(span.resource ? { resource: containerSnapshot(span.resource) } : {}),
|
|
62
|
+
...(span.instrumentationScope ? { instrumentationScope: containerSnapshot(span.instrumentationScope) } : {}),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function stripOperationalUrl(value) {
|
|
66
|
+
try {
|
|
67
|
+
const url = new URL(value);
|
|
68
|
+
return `${url.origin}${url.pathname}`;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return value.split(/[?#]/, 1)[0] ?? "";
|
|
72
|
+
}
|
|
73
|
+
}
|