@keemakr/observability 0.2.0-dev-20261012160211
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 +59 -0
- package/dist/content-policy-processor.d.ts +10 -0
- package/dist/content-policy-processor.js +34 -0
- package/dist/eve-session.d.ts +3 -0
- package/dist/eve-session.js +16 -0
- package/dist/host.d.ts +45 -0
- package/dist/host.js +88 -0
- package/dist/index.d.ts +50 -0
- package/dist/index.js +80 -0
- package/dist/policy.d.ts +18 -0
- package/dist/policy.js +66 -0
- package/dist/sampler.d.ts +8 -0
- package/dist/sampler.js +41 -0
- package/package.json +64 -0
package/README.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# @keemakr/observability
|
|
2
|
+
|
|
3
|
+
Keemakr telemetry, in two entry points. One process registers the host once. Everything else traces
|
|
4
|
+
through the runtime and never learns which backend is behind it.
|
|
5
|
+
|
|
6
|
+
| Entry | Who imports it | What it does |
|
|
7
|
+
| ----------------------------- | ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
8
|
+
| `@keemakr/observability/host` | The one instrumentation file of a process: Core, Operator, Task Operator, a Pack Runtime | Registers OpenTelemetry via `@vercel/otel`, adds the Langfuse span processor when its keys are set, applies the sampler and the content policy. |
|
|
9
|
+
| `@keemakr/observability` | Core libraries, `@keemakr/agent-sdk`, Pack code | Execution-context propagation, trace-context helpers, observation names. Plain OpenTelemetry; a no-op with no host. |
|
|
10
|
+
|
|
11
|
+
This follows the Observability PRD: eve's tracing is the agent span model, the host owns the
|
|
12
|
+
provider and exporter, SDKs only participate, metadata is the default and content is an explicit
|
|
13
|
+
decision, W3C `traceparent` is the propagation contract.
|
|
14
|
+
|
|
15
|
+
## Host
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
// src/instrumentation.ts (Next.js) — or eve's agent/instrumentation.ts `setup`
|
|
19
|
+
import { registerKeemakrTelemetry } from '@keemakr/observability/host';
|
|
20
|
+
|
|
21
|
+
const telemetry = await registerKeemakrTelemetry({ service: 'keemakr-core' });
|
|
22
|
+
// telemetry.destination 'langfuse' | 'otlp' | 'none'
|
|
23
|
+
// telemetry.recordContent the content decision — hand it to eve / the AI SDK
|
|
24
|
+
// telemetry.flush() before a serverless function returns
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
| Variable | Effect |
|
|
28
|
+
| ------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
29
|
+
| `LANGFUSE_PUBLIC_KEY` + `LANGFUSE_SECRET_KEY` | Both set: spans go to Langfuse. `LANGFUSE_BASE_URL` selects the instance (default Langfuse Cloud). |
|
|
30
|
+
| `OTEL_EXPORTER_OTLP_ENDPOINT` | Any OTLP backend, read by `@vercel/otel`. Works alongside Langfuse. |
|
|
31
|
+
| `OTEL_RECORD_CONTENT` | `1`/`true` content on, `0`/`false` off. Unset: on only in `development`. |
|
|
32
|
+
| `VERCEL_GIT_COMMIT_REF`, `VERCEL_GIT_COMMIT_SHA` | Become Langfuse `environment` and `release`. The branch maps `main`→`production`, `stage`→`staging`, `dev`→`dev`; other branches `preview`; a laptop `development`. `VERCEL_ENV` is `production` on all three projects, so it is not used. `LANGFUSE_TRACING_ENVIRONMENT` overrides. |
|
|
33
|
+
|
|
34
|
+
What reaches Langfuse: spans with `gen_ai.*` attributes (eve, the Vercel AI SDK), eve's own spans,
|
|
35
|
+
any `keemakr-*` tracer, and the root request span so a trace has a root. Framework internals and
|
|
36
|
+
outbound `fetch` spans do not. Root spans for `/api/internal/*`, middleware, `/_next/*` and
|
|
37
|
+
`/api/health` are not sampled at all.
|
|
38
|
+
|
|
39
|
+
When content capture is off, `gen_ai.input.messages`, `gen_ai.output.messages`, tool arguments and
|
|
40
|
+
results, and the Langfuse `input`/`output` attributes leave as `[redacted: content capture off]`,
|
|
41
|
+
whichever library produced the span.
|
|
42
|
+
|
|
43
|
+
## Runtime
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import { withExecutionContext, OBSERVATION } from '@keemakr/observability';
|
|
47
|
+
|
|
48
|
+
await withExecutionContext(
|
|
49
|
+
{ component: 'core', lane: 'inline', tenantId, conversationId, contextId, operationId },
|
|
50
|
+
() => invokeCapability(...),
|
|
51
|
+
);
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Every span created inside — by Core, eve, the AI SDK or a library — carries the Langfuse session
|
|
55
|
+
(the conversation, else the task, else the run), tags, and the safe `keemakr` identifiers as trace
|
|
56
|
+
metadata. The tenant is a 12-hex hash, never the id. Nest freely.
|
|
57
|
+
|
|
58
|
+
Observation names are constants in `OBSERVATION`. Langfuse evaluators, dashboards and saved views
|
|
59
|
+
target names, so a rename is a breaking change.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { SpanProcessor } from '@opentelemetry/sdk-trace-base';
|
|
2
|
+
/**
|
|
3
|
+
* Redact native GenAI and Langfuse content before either exporter receives it.
|
|
4
|
+
*
|
|
5
|
+
* A query string is dropped from every span whatever the content decision: it is
|
|
6
|
+
* where bearer values ride when a header is not possible (a provider webhook
|
|
7
|
+
* secret, an OAuth code, a magic-link token), and a trace is never a place for a
|
|
8
|
+
* credential. The path stays, so the request is still identifiable.
|
|
9
|
+
*/
|
|
10
|
+
export declare function contentPolicyProcessor(recordContent: boolean): SpanProcessor;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { isContentAttribute, REDACTED } from './policy.js';
|
|
2
|
+
/** HTTP span attributes that carry the request URL with its query string. */
|
|
3
|
+
const URL_ATTRIBUTES = ['http.target', 'http.url', 'url.full'];
|
|
4
|
+
/**
|
|
5
|
+
* Redact native GenAI and Langfuse content before either exporter receives it.
|
|
6
|
+
*
|
|
7
|
+
* A query string is dropped from every span whatever the content decision: it is
|
|
8
|
+
* where bearer values ride when a header is not possible (a provider webhook
|
|
9
|
+
* secret, an OAuth code, a magic-link token), and a trace is never a place for a
|
|
10
|
+
* credential. The path stays, so the request is still identifiable.
|
|
11
|
+
*/
|
|
12
|
+
export function contentPolicyProcessor(recordContent) {
|
|
13
|
+
return {
|
|
14
|
+
onStart() { },
|
|
15
|
+
onEnd(span) {
|
|
16
|
+
for (const key of URL_ATTRIBUTES) {
|
|
17
|
+
const value = span.attributes[key];
|
|
18
|
+
if (typeof value === 'string' && value.includes('?')) {
|
|
19
|
+
span.attributes[key] = value.slice(0, value.indexOf('?'));
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
if ('url.query' in span.attributes)
|
|
23
|
+
span.attributes['url.query'] = REDACTED;
|
|
24
|
+
if (recordContent)
|
|
25
|
+
return;
|
|
26
|
+
for (const key of Object.keys(span.attributes)) {
|
|
27
|
+
if (isContentAttribute(key))
|
|
28
|
+
span.attributes[key] = REDACTED;
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
async forceFlush() { },
|
|
32
|
+
async shutdown() { },
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { trace } from '@opentelemetry/api';
|
|
2
|
+
/** AI SDK v7 prefixes Eve runtime context; Langfuse expects session.id. */
|
|
3
|
+
export function eveSessionProcessor() {
|
|
4
|
+
return {
|
|
5
|
+
onStart(span, parentContext) {
|
|
6
|
+
const parent = trace.getSpan(parentContext);
|
|
7
|
+
const session = span.attributes['ai.settings.context.keemakr.conversation_id'] ??
|
|
8
|
+
parent?.attributes?.['session.id'];
|
|
9
|
+
if (typeof session === 'string' && session.trim())
|
|
10
|
+
span.setAttribute('session.id', session);
|
|
11
|
+
},
|
|
12
|
+
onEnd() { },
|
|
13
|
+
async forceFlush() { },
|
|
14
|
+
async shutdown() { },
|
|
15
|
+
};
|
|
16
|
+
}
|
package/dist/host.d.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ReadableSpan, SpanProcessor } from '@opentelemetry/sdk-trace-base';
|
|
2
|
+
import { keemakrSampler } from './sampler.js';
|
|
3
|
+
export interface TelemetryHostOptions {
|
|
4
|
+
/** `service.name` on every span. */
|
|
5
|
+
service: string;
|
|
6
|
+
/** Defaults to `telemetryEnvironment()`. */
|
|
7
|
+
environment?: string;
|
|
8
|
+
/** Defaults to `telemetryRelease()`. */
|
|
9
|
+
release?: string;
|
|
10
|
+
/** Defaults to `contentCaptureAllowed()`. */
|
|
11
|
+
recordContent?: boolean;
|
|
12
|
+
env?: NodeJS.ProcessEnv;
|
|
13
|
+
}
|
|
14
|
+
export interface TelemetryHost {
|
|
15
|
+
exporting: boolean;
|
|
16
|
+
destination: 'langfuse' | 'otlp' | 'none';
|
|
17
|
+
environment: string;
|
|
18
|
+
/** Hand this to eve and the AI SDK so every producer obeys one content decision. */
|
|
19
|
+
recordContent: boolean;
|
|
20
|
+
/** Serverless handlers call this before they return. */
|
|
21
|
+
flush(): Promise<void>;
|
|
22
|
+
}
|
|
23
|
+
export declare function langfuseConfigured(env?: NodeJS.ProcessEnv): boolean;
|
|
24
|
+
export declare function otlpConfigured(env?: NodeJS.ProcessEnv): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* On top of the Langfuse SDK default (its own spans, `gen_ai.*`, known LLM
|
|
27
|
+
* scopes): eve's spans, durable workflow spans, Keemakr's tracers, and the host framework's ROOT span
|
|
28
|
+
* so the trace has a root and its children are not orphans in the UI.
|
|
29
|
+
* Framework internals (rendering, fetch, DB) stay out.
|
|
30
|
+
*/
|
|
31
|
+
export declare function shouldExportToLangfuse(span: ReadableSpan): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Keemakr's span pipeline without registering a provider, for a host that
|
|
34
|
+
* owns OpenTelemetry itself (eve's `otelIntegration`). With nothing
|
|
35
|
+
* configured no span leaves.
|
|
36
|
+
*/
|
|
37
|
+
export declare function keemakrTelemetry(opts: TelemetryHostOptions): TelemetryHost & {
|
|
38
|
+
spanProcessors: SpanProcessor[];
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Idempotent per process (`@vercel/otel` guards). With nothing configured the
|
|
42
|
+
* provider is still installed so context propagates, and no span leaves.
|
|
43
|
+
*/
|
|
44
|
+
export declare function registerKeemakrTelemetry(opts: TelemetryHostOptions): Promise<TelemetryHost>;
|
|
45
|
+
export { keemakrSampler };
|
package/dist/host.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// The one module per process that owns OpenTelemetry (Observability PRD: the
|
|
2
|
+
// host owns provider, sampling, exporters and shutdown; libraries participate).
|
|
3
|
+
// Only this file knows Langfuse exists; the runtime entry is exporter-neutral.
|
|
4
|
+
import { LangfuseSpanProcessor, isDefaultExportSpan } from '@langfuse/otel';
|
|
5
|
+
import { contentCaptureAllowed, telemetryEnvironment, telemetryRelease } from './policy.js';
|
|
6
|
+
import { keemakrSampler } from './sampler.js';
|
|
7
|
+
import { KEEMAKR_TRACER_PREFIX } from './index.js';
|
|
8
|
+
import { eveSessionProcessor } from './eve-session.js';
|
|
9
|
+
import { contentPolicyProcessor } from './content-policy-processor.js';
|
|
10
|
+
export function langfuseConfigured(env = process.env) {
|
|
11
|
+
return Boolean(env.LANGFUSE_PUBLIC_KEY?.trim() && env.LANGFUSE_SECRET_KEY?.trim());
|
|
12
|
+
}
|
|
13
|
+
export function otlpConfigured(env = process.env) {
|
|
14
|
+
return Boolean(env.OTEL_EXPORTER_OTLP_ENDPOINT || env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* On top of the Langfuse SDK default (its own spans, `gen_ai.*`, known LLM
|
|
18
|
+
* scopes): eve's spans, durable workflow spans, Keemakr's tracers, and the host framework's ROOT span
|
|
19
|
+
* so the trace has a root and its children are not orphans in the UI.
|
|
20
|
+
* Framework internals (rendering, fetch, DB) stay out.
|
|
21
|
+
*/
|
|
22
|
+
export function shouldExportToLangfuse(span) {
|
|
23
|
+
if (isDefaultExportSpan(span))
|
|
24
|
+
return true;
|
|
25
|
+
const scope = span.instrumentationScope.name;
|
|
26
|
+
// Eve's durable workflow steps are ancestors of ai.eve.turn.
|
|
27
|
+
if (scope === 'workflow')
|
|
28
|
+
return true;
|
|
29
|
+
if (scope === 'eve' || scope.startsWith('eve/'))
|
|
30
|
+
return true;
|
|
31
|
+
if (scope.startsWith(KEEMAKR_TRACER_PREFIX))
|
|
32
|
+
return true;
|
|
33
|
+
return span.parentSpanContext === undefined;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Keemakr's span pipeline without registering a provider, for a host that
|
|
37
|
+
* owns OpenTelemetry itself (eve's `otelIntegration`). With nothing
|
|
38
|
+
* configured no span leaves.
|
|
39
|
+
*/
|
|
40
|
+
export function keemakrTelemetry(opts) {
|
|
41
|
+
const env = opts.env ?? process.env;
|
|
42
|
+
const environment = opts.environment ?? telemetryEnvironment(env);
|
|
43
|
+
const recordContent = opts.recordContent ?? contentCaptureAllowed(env);
|
|
44
|
+
const langfuse = langfuseConfigured(env);
|
|
45
|
+
const otlp = otlpConfigured(env);
|
|
46
|
+
const processor = langfuse
|
|
47
|
+
? new LangfuseSpanProcessor({
|
|
48
|
+
environment,
|
|
49
|
+
release: opts.release ?? telemetryRelease(env),
|
|
50
|
+
shouldExportSpan: ({ otelSpan }) => shouldExportToLangfuse(otelSpan),
|
|
51
|
+
// A serverless function can be frozen right after it responds.
|
|
52
|
+
exportMode: env.VERCEL ? 'immediate' : 'batched',
|
|
53
|
+
})
|
|
54
|
+
: null;
|
|
55
|
+
return {
|
|
56
|
+
spanProcessors: [
|
|
57
|
+
contentPolicyProcessor(recordContent),
|
|
58
|
+
eveSessionProcessor(),
|
|
59
|
+
...(processor ? [processor] : []),
|
|
60
|
+
],
|
|
61
|
+
exporting: langfuse || otlp,
|
|
62
|
+
destination: langfuse ? 'langfuse' : otlp ? 'otlp' : 'none',
|
|
63
|
+
environment,
|
|
64
|
+
recordContent,
|
|
65
|
+
flush: async () => {
|
|
66
|
+
await processor?.forceFlush();
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Idempotent per process (`@vercel/otel` guards). With nothing configured the
|
|
72
|
+
* provider is still installed so context propagates, and no span leaves.
|
|
73
|
+
*/
|
|
74
|
+
export async function registerKeemakrTelemetry(opts) {
|
|
75
|
+
const { spanProcessors, ...host } = keemakrTelemetry(opts);
|
|
76
|
+
const [contentPolicy, eveSession, ...exporters] = spanProcessors;
|
|
77
|
+
// `@vercel/otel` is an optional peer and its Node bundle must load as ESM.
|
|
78
|
+
const { registerOTel } = await import('@vercel/otel');
|
|
79
|
+
registerOTel({
|
|
80
|
+
serviceName: opts.service,
|
|
81
|
+
attributes: { 'deployment.environment.name': host.environment },
|
|
82
|
+
traceSampler: keemakrSampler(),
|
|
83
|
+
// 'auto' keeps @vercel/otel's own exporter (Vercel tracing or OTEL_EXPORTER_OTLP_*).
|
|
84
|
+
spanProcessors: [contentPolicy, eveSession, 'auto', ...exporters],
|
|
85
|
+
});
|
|
86
|
+
return host;
|
|
87
|
+
}
|
|
88
|
+
export { keemakrSampler };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export { getActiveSpanId, getActiveTraceId, observe, startActiveObservation, startObservation, updateActiveObservation, type LangfuseObservationType, } from '@langfuse/tracing';
|
|
2
|
+
export { contentCaptureAllowed, telemetryEnvironment } from './policy.js';
|
|
3
|
+
/** Every Keemakr tracer name starts with this, so a host can allowlist them. */
|
|
4
|
+
export declare const KEEMAKR_TRACER_PREFIX = "keemakr";
|
|
5
|
+
export declare function keemakrTracer(component: string): import("@opentelemetry/api").Tracer;
|
|
6
|
+
export type ExecutionLane = 'inline' | 'agent_task' | 'workflow_task' | 'delegation' | 'planning';
|
|
7
|
+
/** Durable Keemakr identifiers only. Never a raw tenant, user, or content (PRD). */
|
|
8
|
+
export interface ExecutionIdentity {
|
|
9
|
+
component: 'core' | 'operator' | 'task-operator' | 'agent-sdk' | (string & {});
|
|
10
|
+
lane?: ExecutionLane;
|
|
11
|
+
tenantId?: string | null;
|
|
12
|
+
conversationId?: string | null;
|
|
13
|
+
taskId?: string | null;
|
|
14
|
+
runId?: string | null;
|
|
15
|
+
contextId?: string | null;
|
|
16
|
+
parentContextId?: string | null;
|
|
17
|
+
operationId?: string | null;
|
|
18
|
+
capabilityId?: string | null;
|
|
19
|
+
workflowId?: string | null;
|
|
20
|
+
delegationId?: string | null;
|
|
21
|
+
callerKind?: string | null;
|
|
22
|
+
/** A version to compare across: the Pack version, a configuration version. */
|
|
23
|
+
version?: string | null;
|
|
24
|
+
}
|
|
25
|
+
/** A tenant is never exported raw (PRD: opaque identifiers for third parties). */
|
|
26
|
+
export declare function opaqueTenant(tenantId: string): string;
|
|
27
|
+
export declare function sessionIdFor(id: ExecutionIdentity): string | undefined;
|
|
28
|
+
export declare function propagatedAttributes(id: ExecutionIdentity): {
|
|
29
|
+
sessionId: string | undefined;
|
|
30
|
+
tags: string[];
|
|
31
|
+
metadata: Record<string, string>;
|
|
32
|
+
version: string | undefined;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Put this execution's identity on EVERY span beneath `fn`, whoever creates it.
|
|
36
|
+
* Langfuse filters per observation, so an attribute only on the root is
|
|
37
|
+
* invisible to a filter on a child. Nested contexts add to the outer one.
|
|
38
|
+
*/
|
|
39
|
+
export declare function withExecutionContext<T>(id: ExecutionIdentity, fn: () => T): T;
|
|
40
|
+
export declare function isTraceId(id: string | null | undefined): id is string;
|
|
41
|
+
/** A reference for a durable row to remember, never a key (PRD: telemetry identity is not product identity). */
|
|
42
|
+
export declare function activeTraceId(): string | null;
|
|
43
|
+
/** Add `traceparent` and `tracestate` to headers for a call leaving this process. */
|
|
44
|
+
export declare function injectTraceparent(headers: Record<string, string>): Record<string, string>;
|
|
45
|
+
/** Langfuse evaluators, dashboards and saved views target these names. Renaming one breaks them. */
|
|
46
|
+
export declare const OBSERVATION: {
|
|
47
|
+
readonly capabilityInvoke: "capability.invoke";
|
|
48
|
+
readonly workflowDispatch: "workflow.dispatch";
|
|
49
|
+
};
|
|
50
|
+
export type ObservationName = (typeof OBSERVATION)[keyof typeof OBSERVATION];
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// The exporter-neutral runtime entry. With no host registered every call is a
|
|
2
|
+
// no-op, and nothing here may throw into the caller.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { context, propagation, trace } from '@opentelemetry/api';
|
|
5
|
+
import { propagateAttributes } from '@langfuse/tracing';
|
|
6
|
+
export { getActiveSpanId, getActiveTraceId, observe, startActiveObservation, startObservation, updateActiveObservation, } from '@langfuse/tracing';
|
|
7
|
+
export { contentCaptureAllowed, telemetryEnvironment } from './policy.js';
|
|
8
|
+
/** Every Keemakr tracer name starts with this, so a host can allowlist them. */
|
|
9
|
+
export const KEEMAKR_TRACER_PREFIX = 'keemakr';
|
|
10
|
+
export function keemakrTracer(component) {
|
|
11
|
+
return trace.getTracer(`${KEEMAKR_TRACER_PREFIX}-${component}`);
|
|
12
|
+
}
|
|
13
|
+
/** A tenant is never exported raw (PRD: opaque identifiers for third parties). */
|
|
14
|
+
export function opaqueTenant(tenantId) {
|
|
15
|
+
return createHash('sha256').update(tenantId).digest('hex').slice(0, 12);
|
|
16
|
+
}
|
|
17
|
+
export function sessionIdFor(id) {
|
|
18
|
+
return id.conversationId ?? id.taskId ?? id.runId ?? undefined;
|
|
19
|
+
}
|
|
20
|
+
export function propagatedAttributes(id) {
|
|
21
|
+
const metadata = { component: id.component };
|
|
22
|
+
const put = (k, v) => {
|
|
23
|
+
if (v)
|
|
24
|
+
metadata[k] = v;
|
|
25
|
+
};
|
|
26
|
+
put('lane', id.lane);
|
|
27
|
+
put('tenant', id.tenantId ? opaqueTenant(id.tenantId) : undefined);
|
|
28
|
+
put('conversation_id', id.conversationId);
|
|
29
|
+
put('task_id', id.taskId);
|
|
30
|
+
put('run_id', id.runId);
|
|
31
|
+
put('context_id', id.contextId);
|
|
32
|
+
put('parent_context_id', id.parentContextId);
|
|
33
|
+
put('operation_id', id.operationId);
|
|
34
|
+
put('capability_id', id.capabilityId);
|
|
35
|
+
put('workflow_id', id.workflowId);
|
|
36
|
+
put('delegation_id', id.delegationId);
|
|
37
|
+
put('caller_kind', id.callerKind);
|
|
38
|
+
const tags = [id.component, id.lane, id.callerKind].filter((t) => !!t);
|
|
39
|
+
return {
|
|
40
|
+
sessionId: sessionIdFor(id),
|
|
41
|
+
tags,
|
|
42
|
+
metadata,
|
|
43
|
+
version: id.version ?? undefined,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Put this execution's identity on EVERY span beneath `fn`, whoever creates it.
|
|
48
|
+
* Langfuse filters per observation, so an attribute only on the root is
|
|
49
|
+
* invisible to a filter on a child. Nested contexts add to the outer one.
|
|
50
|
+
*/
|
|
51
|
+
export function withExecutionContext(id, fn) {
|
|
52
|
+
// propagateAttributes drops a bad value with a warning and never throws.
|
|
53
|
+
return propagateAttributes(propagatedAttributes(id), fn);
|
|
54
|
+
}
|
|
55
|
+
const W3C_TRACE_ID = /^[0-9a-f]{32}$/;
|
|
56
|
+
const NO_TRACE = '0'.repeat(32);
|
|
57
|
+
export function isTraceId(id) {
|
|
58
|
+
return !!id && W3C_TRACE_ID.test(id) && id !== NO_TRACE;
|
|
59
|
+
}
|
|
60
|
+
/** A reference for a durable row to remember, never a key (PRD: telemetry identity is not product identity). */
|
|
61
|
+
export function activeTraceId() {
|
|
62
|
+
const id = trace.getSpanContext(context.active())?.traceId;
|
|
63
|
+
return isTraceId(id) ? id : null;
|
|
64
|
+
}
|
|
65
|
+
/** Add `traceparent` and `tracestate` to headers for a call leaving this process. */
|
|
66
|
+
export function injectTraceparent(headers) {
|
|
67
|
+
const out = { ...headers };
|
|
68
|
+
try {
|
|
69
|
+
propagation.inject(context.active(), out);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// no propagator installed: the headers go out without trace context
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
/** Langfuse evaluators, dashboards and saved views target these names. Renaming one breaks them. */
|
|
77
|
+
export const OBSERVATION = {
|
|
78
|
+
capabilityInvoke: 'capability.invoke',
|
|
79
|
+
workflowDispatch: 'workflow.dispatch',
|
|
80
|
+
};
|
package/dist/policy.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export type TelemetryEnvironment = 'development' | 'dev' | 'staging' | 'production' | 'preview' | (string & {});
|
|
2
|
+
/** The Langfuse `environment` of this process. `LANGFUSE_TRACING_ENVIRONMENT`
|
|
3
|
+
* when set; else the tier from the deployed branch; a feature-branch deploy is
|
|
4
|
+
* `preview`; a machine with no Vercel context is `development`. */
|
|
5
|
+
export declare function telemetryEnvironment(env?: NodeJS.ProcessEnv): TelemetryEnvironment;
|
|
6
|
+
export declare function telemetryRelease(env?: NodeJS.ProcessEnv): string | undefined;
|
|
7
|
+
/**
|
|
8
|
+
* OTEL_RECORD_CONTENT=1|true yes, an explicit decision for this deployment
|
|
9
|
+
* OTEL_RECORD_CONTENT=0|false no
|
|
10
|
+
* unset only in `development`
|
|
11
|
+
*/
|
|
12
|
+
export declare function contentCaptureAllowed(env?: NodeJS.ProcessEnv): boolean;
|
|
13
|
+
/** A marker, so a reader knows content existed and was withheld. */
|
|
14
|
+
export declare const REDACTED = "[redacted: content capture off]";
|
|
15
|
+
/** `langfuse.observation.*` is the Langfuse SDK, `gen_ai.*` the OpenTelemetry
|
|
16
|
+
* GenAI convention (eve, the AI SDK), `ai.*` the AI SDK's own names. */
|
|
17
|
+
export declare const CONTENT_ATTRIBUTE_PREFIXES: readonly string[];
|
|
18
|
+
export declare function isContentAttribute(key: string): boolean;
|
package/dist/policy.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// The one decision on whether model and tool content may leave a process
|
|
2
|
+
// (Observability PRD: metadata by default, content by explicit destination decision).
|
|
3
|
+
/** Keemakr runs three Vercel projects, one per branch, and each is that
|
|
4
|
+
* project's "production" deployment — so `VERCEL_ENV` is `production` on all
|
|
5
|
+
* three and cannot tell them apart. The branch can (docs/prod-git-rules.md). */
|
|
6
|
+
const BRANCH_ENVIRONMENT = {
|
|
7
|
+
main: 'production',
|
|
8
|
+
stage: 'staging',
|
|
9
|
+
dev: 'dev',
|
|
10
|
+
};
|
|
11
|
+
/** The Langfuse `environment` of this process. `LANGFUSE_TRACING_ENVIRONMENT`
|
|
12
|
+
* when set; else the tier from the deployed branch; a feature-branch deploy is
|
|
13
|
+
* `preview`; a machine with no Vercel context is `development`. */
|
|
14
|
+
export function telemetryEnvironment(env = process.env) {
|
|
15
|
+
const explicit = env.LANGFUSE_TRACING_ENVIRONMENT?.trim();
|
|
16
|
+
if (explicit)
|
|
17
|
+
return explicit;
|
|
18
|
+
const branch = env.VERCEL_GIT_COMMIT_REF?.trim();
|
|
19
|
+
if (branch)
|
|
20
|
+
return BRANCH_ENVIRONMENT[branch] ?? 'preview';
|
|
21
|
+
if (env.VERCEL)
|
|
22
|
+
return 'preview';
|
|
23
|
+
return 'development';
|
|
24
|
+
}
|
|
25
|
+
export function telemetryRelease(env = process.env) {
|
|
26
|
+
return env.LANGFUSE_RELEASE?.trim() || env.VERCEL_GIT_COMMIT_SHA?.trim() || undefined;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* OTEL_RECORD_CONTENT=1|true yes, an explicit decision for this deployment
|
|
30
|
+
* OTEL_RECORD_CONTENT=0|false no
|
|
31
|
+
* unset only in `development`
|
|
32
|
+
*/
|
|
33
|
+
export function contentCaptureAllowed(env = process.env) {
|
|
34
|
+
const flag = env.OTEL_RECORD_CONTENT?.trim().toLowerCase();
|
|
35
|
+
if (flag === '1' || flag === 'true')
|
|
36
|
+
return true;
|
|
37
|
+
if (flag === '0' || flag === 'false')
|
|
38
|
+
return false;
|
|
39
|
+
return telemetryEnvironment(env) === 'development';
|
|
40
|
+
}
|
|
41
|
+
/** A marker, so a reader knows content existed and was withheld. */
|
|
42
|
+
export const REDACTED = '[redacted: content capture off]';
|
|
43
|
+
/** `langfuse.observation.*` is the Langfuse SDK, `gen_ai.*` the OpenTelemetry
|
|
44
|
+
* GenAI convention (eve, the AI SDK), `ai.*` the AI SDK's own names. */
|
|
45
|
+
export const CONTENT_ATTRIBUTE_PREFIXES = [
|
|
46
|
+
'langfuse.observation.input',
|
|
47
|
+
'langfuse.observation.output',
|
|
48
|
+
'langfuse.trace.input',
|
|
49
|
+
'langfuse.trace.output',
|
|
50
|
+
'gen_ai.input.messages',
|
|
51
|
+
'gen_ai.output.messages',
|
|
52
|
+
'gen_ai.prompt',
|
|
53
|
+
'gen_ai.completion',
|
|
54
|
+
'gen_ai.system_instructions',
|
|
55
|
+
'gen_ai.tool.call.arguments',
|
|
56
|
+
'gen_ai.tool.call.result',
|
|
57
|
+
'ai.prompt',
|
|
58
|
+
'ai.response.text',
|
|
59
|
+
'ai.response.object',
|
|
60
|
+
'ai.response.toolCalls',
|
|
61
|
+
'ai.toolCall.args',
|
|
62
|
+
'ai.toolCall.result',
|
|
63
|
+
];
|
|
64
|
+
export function isContentAttribute(key) {
|
|
65
|
+
return CONTENT_ATTRIBUTE_PREFIXES.some((p) => key === p || key.startsWith(`${p}.`));
|
|
66
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type Attributes, type Context } from '@opentelemetry/api';
|
|
2
|
+
export declare function keepRootSpan(name: string, attributes: Attributes): boolean;
|
|
3
|
+
export declare function keemakrSampler(): {
|
|
4
|
+
shouldSample(ctx: Context, _traceId: string, name: string, _kind: unknown, attrs: Attributes): {
|
|
5
|
+
decision: number;
|
|
6
|
+
};
|
|
7
|
+
toString(): string;
|
|
8
|
+
};
|
package/dist/sampler.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Parent-based, including a parent propagated in over `traceparent`, so one
|
|
2
|
+
// Keemakr execution is kept or dropped whole across deployments.
|
|
3
|
+
import { trace, TraceFlags } from '@opentelemetry/api';
|
|
4
|
+
/** Root spans nobody debugs from, which would otherwise be most of what a backend stores. */
|
|
5
|
+
const NOISE_ROUTES = [/^\/api\/internal\//, /^\/_next\//, /^\/api\/health$/, /^\/favicon/];
|
|
6
|
+
function routeOf(name, attributes) {
|
|
7
|
+
for (const key of ['http.route', 'next.route', 'http.target', 'url.path']) {
|
|
8
|
+
const v = attributes[key];
|
|
9
|
+
if (typeof v === 'string' && v)
|
|
10
|
+
return v;
|
|
11
|
+
}
|
|
12
|
+
const m = /^(?:GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(\/\S*)/.exec(name);
|
|
13
|
+
return m ? m[1] : null;
|
|
14
|
+
}
|
|
15
|
+
export function keepRootSpan(name, attributes) {
|
|
16
|
+
if (name === 'middleware' || name.startsWith('middleware '))
|
|
17
|
+
return false;
|
|
18
|
+
const route = routeOf(name, attributes);
|
|
19
|
+
if (route && NOISE_ROUTES.some((re) => re.test(route)))
|
|
20
|
+
return false;
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
// @opentelemetry/sdk-trace-base's SamplingDecision values, so this stays a
|
|
24
|
+
// structural sampler with no runtime import of that package.
|
|
25
|
+
const NOT_RECORD = 0;
|
|
26
|
+
const RECORD_AND_SAMPLED = 2;
|
|
27
|
+
export function keemakrSampler() {
|
|
28
|
+
return {
|
|
29
|
+
shouldSample(ctx, _traceId, name, _kind, attrs) {
|
|
30
|
+
const parent = trace.getSpanContext(ctx);
|
|
31
|
+
if (parent && trace.isSpanContextValid(parent)) {
|
|
32
|
+
const sampled = (parent.traceFlags & TraceFlags.SAMPLED) !== 0;
|
|
33
|
+
return { decision: sampled ? RECORD_AND_SAMPLED : NOT_RECORD };
|
|
34
|
+
}
|
|
35
|
+
return { decision: keepRootSpan(name, attrs ?? {}) ? RECORD_AND_SAMPLED : NOT_RECORD };
|
|
36
|
+
},
|
|
37
|
+
toString() {
|
|
38
|
+
return 'KeemakrSampler';
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@keemakr/observability",
|
|
3
|
+
"version": "0.2.0-dev-20261012160211",
|
|
4
|
+
"description": "Keemakr telemetry: the host registration for a process (Core, Operator, Task Operator, Pack Runtime) and the exporter-neutral runtime that Core libraries, the Agents SDK and Pack code trace with.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./host": {
|
|
16
|
+
"types": "./dist/host.d.ts",
|
|
17
|
+
"import": "./dist/host.js",
|
|
18
|
+
"default": "./dist/host.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"README.md"
|
|
24
|
+
],
|
|
25
|
+
"publishConfig": {
|
|
26
|
+
"access": "public"
|
|
27
|
+
},
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "git+https://github.com/fsztpartners/keemakr.git",
|
|
31
|
+
"directory": "packages/observability"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"build": "tsc -p tsconfig.json",
|
|
35
|
+
"typecheck": "tsc --noEmit",
|
|
36
|
+
"prepack": "npm run build"
|
|
37
|
+
},
|
|
38
|
+
"dependencies": {
|
|
39
|
+
"@langfuse/otel": "^5.11.1",
|
|
40
|
+
"@langfuse/tracing": "^5.11.1"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@opentelemetry/api": "^1.9.0",
|
|
44
|
+
"@opentelemetry/core": "^2.0.1",
|
|
45
|
+
"@opentelemetry/sdk-trace-base": "^2.0.1",
|
|
46
|
+
"@vercel/otel": "^2.1.3"
|
|
47
|
+
},
|
|
48
|
+
"peerDependenciesMeta": {
|
|
49
|
+
"@vercel/otel": {
|
|
50
|
+
"optional": true
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@opentelemetry/api": "1.9.1",
|
|
55
|
+
"@opentelemetry/core": "^2.0.1",
|
|
56
|
+
"@opentelemetry/sdk-trace-base": "^2.0.1",
|
|
57
|
+
"@types/node": "^20.19.43",
|
|
58
|
+
"@vercel/otel": "^2.1.3",
|
|
59
|
+
"typescript": "^5"
|
|
60
|
+
},
|
|
61
|
+
"engines": {
|
|
62
|
+
"node": ">=20.9"
|
|
63
|
+
}
|
|
64
|
+
}
|