@vesta-analytics/sdk 0.1.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.
@@ -0,0 +1,40 @@
1
+ /**
2
+ * McpAdapter — instruments the official `@modelcontextprotocol/sdk`.
3
+ *
4
+ * Covers both shapes a customer uses:
5
+ * - the low-level `Server` (`server/index.js`), and
6
+ * - the high-level `McpServer` (`server/mcp.js`), which wraps a low-level
7
+ * `Server` reachable via `.server`.
8
+ *
9
+ * Both expose the same `_requestHandlers` map, so the shared
10
+ * `installLowlevelWrap` handles them identically.
11
+ *
12
+ * Importing this module never hard-requires `@modelcontextprotocol/sdk`; it is
13
+ * an optional peer dependency. Detection is purely structural.
14
+ */
15
+ import { installLowlevelWrap, isLowlevelServer, lowlevelHandlers } from './lowlevel.js';
16
+ function lowlevelOf(server) {
17
+ if (isLowlevelServer(server))
18
+ return server;
19
+ if (server !== null && typeof server === 'object') {
20
+ const inner = server['server'];
21
+ if (isLowlevelServer(inner))
22
+ return inner;
23
+ }
24
+ return null;
25
+ }
26
+ export class McpAdapter {
27
+ name = 'mcp';
28
+ owns(server) {
29
+ return lowlevelOf(server) !== null;
30
+ }
31
+ wrap(server, onInvocation) {
32
+ const low = lowlevelOf(server);
33
+ if (low === null)
34
+ return;
35
+ // The official Server exposes the negotiated clientInfo via getClientVersion()
36
+ // — read per call so each span is attributable to its app, not just initialize.
37
+ const getClientInfo = () => low.getClientVersion?.();
38
+ installLowlevelWrap(lowlevelHandlers(low), 'mcp', onInvocation, undefined, getClientInfo);
39
+ }
40
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Pure attribute builders. Keys match the vesta_core OTel translator exactly
3
+ * — this is the wire-format parity seam with the Python SDK's `capture.py`.
4
+ *
5
+ * No OTel-SDK or framework imports here. `export.ts` turns these dicts into a
6
+ * real OTel span. Every value is a wire-safe scalar (string / number /
7
+ * boolean); payloads are JSON-encoded into string attributes, which the
8
+ * ingest translator decodes structurally (so JS-vs-Python JSON spacing is
9
+ * irrelevant to parity).
10
+ */
11
+ export type AttrValue = string | number | boolean;
12
+ export interface SpanAttributesInput {
13
+ surfaceId: string;
14
+ toolName: string;
15
+ toolArguments: unknown;
16
+ toolResult: unknown;
17
+ sessionId: string | null;
18
+ sdkVersion: string;
19
+ dimensions: Record<string, unknown>;
20
+ userId: string | null;
21
+ linkedTraceId: string | null;
22
+ linkedSpanId: string | null;
23
+ method?: string;
24
+ extraAttributes?: Record<string, unknown> | null;
25
+ redactionCount?: number | null;
26
+ redactionPaths?: string[] | null;
27
+ }
28
+ export declare function spanAttributes(input: SpanAttributesInput): Record<string, AttrValue>;
29
+ export declare function resourceAttributes(input: {
30
+ agentRuntime: string;
31
+ agentRuntimeVersion: string;
32
+ transport?: string;
33
+ }): Record<string, string>;
34
+ /** Which client app drove this call, from the connection's MCP `clientInfo`
35
+ * (an Implementation = name + version: "claude-ai", "Cursor", ...). Stamped on
36
+ * EVERY span (not just the handshake) so each call is attributable to its app
37
+ * without joining back to initialize — the high-coverage identity signal,
38
+ * unlike the model (see `baggageModelAttrs`). Mirror of Python
39
+ * `client_identity_attrs`; emits the same `mcp.client.*` keys the initialize
40
+ * extractor uses, so both promote through the one translator path. Fail-safe. */
41
+ export declare function clientIdentityAttrs(clientInfo: any): Record<string, AttrValue>;
42
+ /** Model identity an instrumented client propagated in trace `baggage`. `meta`
43
+ * is the request's `_meta` object (where MCP carries propagated context — see
44
+ * propagation.ts). Promotes OTel-canonical `gen_ai.request.model` /
45
+ * `gen_ai.provider.name` onto the span (the translator maps them to the
46
+ * model_id / model_provider columns). `{}` when baggage is absent or carries no
47
+ * model keys — the common case, since most third-party clients don't propagate
48
+ * this. Mirror of Python `baggage_model_attrs`. */
49
+ export declare function baggageModelAttrs(meta: unknown): Record<string, AttrValue>;
50
+ /** Shape of a tools/call result, without its content: the MCP `isError` flag
51
+ * (tool-reported failure, distinct from a transport/protocol error), whether
52
+ * `structuredContent` was returned, and the set of content block types. `{}`
53
+ * for anything that isn't tool-result-shaped. */
54
+ export declare function resultShapeAttrs(result: any): Record<string, AttrValue>;
55
+ /** Privacy-safe summary of a call's arguments: sorted top-level keys, count,
56
+ * and emptiness. Keys only — never values. Non-object args yield `{}`. */
57
+ export declare function argumentShapeAttrs(args: unknown): Record<string, AttrValue>;
58
+ /** The negotiated contract from initialize: which capabilities each side
59
+ * advertised, and whether the server shipped natural-language `instructions`
60
+ * (its 'how to use me' to agents — an editable lever). The text is already in
61
+ * the (redaction-bypassed) initialize payload, so only presence + length here. */
62
+ export declare function handshakeContractAttrs(params: any, result: any): Record<string, AttrValue>;
63
+ /** Server-side identity from the request's validated access token (TS SDK:
64
+ * `extra.authInfo`). Where the verifier exposes a subject claim, emit a
65
+ * pseudonymous, issuer-qualified `user_id = hash(iss, sub)` (never the raw
66
+ * subject, never the token). Always capture `scopes` + `client_id` when present.
67
+ * No subject → no user id. A service/client-credentials token frequently carries
68
+ * `sub === client_id` (no human principal); don't mint a "user" from an app
69
+ * identity — emit a user id only for a subject distinct from the client. */
70
+ export declare function authIdentityAttrs(authInfo: any): Record<string, AttrValue>;
@@ -0,0 +1,266 @@
1
+ /**
2
+ * Pure attribute builders. Keys match the vesta_core OTel translator exactly
3
+ * — this is the wire-format parity seam with the Python SDK's `capture.py`.
4
+ *
5
+ * No OTel-SDK or framework imports here. `export.ts` turns these dicts into a
6
+ * real OTel span. Every value is a wire-safe scalar (string / number /
7
+ * boolean); payloads are JSON-encoded into string attributes, which the
8
+ * ingest translator decodes structurally (so JS-vs-Python JSON spacing is
9
+ * irrelevant to parity).
10
+ */
11
+ import { createHash } from 'node:crypto';
12
+ /** JSON encoder mirroring Python's `json.dumps(x, default=str)`: BigInt and
13
+ * other non-JSON scalars fall back to their string form rather than throwing. */
14
+ function jsonEncode(value) {
15
+ return JSON.stringify(value, (_key, v) => {
16
+ if (typeof v === 'bigint')
17
+ return v.toString();
18
+ if (typeof v === 'function' || typeof v === 'symbol')
19
+ return String(v);
20
+ return v;
21
+ });
22
+ }
23
+ export function spanAttributes(input) {
24
+ const { surfaceId, toolName, toolArguments, toolResult, sessionId, sdkVersion, dimensions, userId, linkedTraceId, linkedSpanId, method = 'tools/call', extraAttributes, redactionCount, redactionPaths, } = input;
25
+ const attrs = {
26
+ 'vesta.surface_id': surfaceId,
27
+ 'mcp.tool': toolName,
28
+ 'mcp.method.name': method,
29
+ 'vesta.sdk_version': sdkVersion,
30
+ };
31
+ // SemConv-canonical primary-id key, per method. Dual-write alongside mcp.tool.
32
+ if (method === 'tools/call' && toolName) {
33
+ attrs['gen_ai.tool.name'] = toolName;
34
+ }
35
+ else if (method === 'prompts/get' && toolName) {
36
+ attrs['gen_ai.prompt.name'] = toolName;
37
+ }
38
+ else if (method === 'resources/read' && toolName) {
39
+ attrs['mcp.resource.uri'] = toolName;
40
+ }
41
+ if (toolArguments !== null && toolArguments !== undefined) {
42
+ attrs['gen_ai.tool.call.arguments'] = jsonEncode(toolArguments);
43
+ }
44
+ if (toolResult !== null && toolResult !== undefined) {
45
+ attrs['gen_ai.tool.call.result'] = jsonEncode(toolResult);
46
+ }
47
+ if (sessionId !== null && sessionId !== undefined) {
48
+ attrs['mcp.session.id'] = sessionId;
49
+ }
50
+ if (userId !== null && userId !== undefined) {
51
+ attrs['vesta.dim.user_id'] = userId;
52
+ }
53
+ for (const [k, v] of Object.entries(dimensions ?? {})) {
54
+ if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
55
+ attrs[`vesta.dim.${k}`] = v;
56
+ }
57
+ else if (v !== null && v !== undefined) {
58
+ attrs[`vesta.dim.${k}`] = String(v);
59
+ }
60
+ }
61
+ if (linkedTraceId && linkedSpanId) {
62
+ attrs['vesta.linked'] = true;
63
+ attrs['vesta.linked_trace_id'] = linkedTraceId;
64
+ attrs['vesta.linked_span_id'] = linkedSpanId;
65
+ }
66
+ else {
67
+ attrs['vesta.linked'] = false;
68
+ }
69
+ if (redactionCount !== null && redactionCount !== undefined) {
70
+ attrs['vesta.redaction.count'] = Math.trunc(redactionCount);
71
+ }
72
+ if (redactionPaths !== null && redactionPaths !== undefined) {
73
+ attrs['vesta.redaction.paths'] = jsonEncode(redactionPaths);
74
+ }
75
+ // Method-specific extras (handshake fields on initialize, etc.). Merged last
76
+ // with set-if-absent semantics so they cannot clobber a load-bearing core
77
+ // attribute (parity with Python's setdefault).
78
+ if (extraAttributes) {
79
+ for (const [k, v] of Object.entries(extraAttributes)) {
80
+ if (k in attrs)
81
+ continue;
82
+ if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') {
83
+ attrs[k] = v;
84
+ }
85
+ }
86
+ }
87
+ return attrs;
88
+ }
89
+ export function resourceAttributes(input) {
90
+ return {
91
+ 'service.name': input.agentRuntime,
92
+ 'service.version': input.agentRuntimeVersion,
93
+ 'vesta.transport': input.transport ?? 'unknown',
94
+ };
95
+ }
96
+ /** Which client app drove this call, from the connection's MCP `clientInfo`
97
+ * (an Implementation = name + version: "claude-ai", "Cursor", ...). Stamped on
98
+ * EVERY span (not just the handshake) so each call is attributable to its app
99
+ * without joining back to initialize — the high-coverage identity signal,
100
+ * unlike the model (see `baggageModelAttrs`). Mirror of Python
101
+ * `client_identity_attrs`; emits the same `mcp.client.*` keys the initialize
102
+ * extractor uses, so both promote through the one translator path. Fail-safe. */
103
+ export function clientIdentityAttrs(clientInfo) {
104
+ if (clientInfo === null || clientInfo === undefined)
105
+ return {};
106
+ const attrs = {};
107
+ if (clientInfo.name)
108
+ attrs['mcp.client.name'] = String(clientInfo.name);
109
+ if (clientInfo.version)
110
+ attrs['mcp.client.version'] = String(clientInfo.version);
111
+ return attrs;
112
+ }
113
+ // OTel-canonical GenAI keys. MCP's own SemConv omits model identity from MCP
114
+ // spans (the server is downstream of the model), so these are read from W3C
115
+ // baggage an instrumented client *opts in* to propagate — sparse by nature.
116
+ const BAGGAGE_MODEL_KEY = 'gen_ai.request.model';
117
+ const BAGGAGE_PROVIDER_KEY = 'gen_ai.provider.name';
118
+ /** Parse a W3C `baggage` header value (`k=v,k2=v2`). Per-member `;properties`
119
+ * are stripped and values percent-decoded. Permissive: members without `=` are
120
+ * skipped, never throwing. */
121
+ function parseBaggage(raw) {
122
+ const out = {};
123
+ for (const member of raw.split(',')) {
124
+ const idx = member.indexOf('=');
125
+ if (idx < 0)
126
+ continue;
127
+ const key = member.slice(0, idx).trim();
128
+ const value = member.slice(idx + 1).split(';', 1)[0].trim(); // drop member-properties
129
+ if (!key)
130
+ continue;
131
+ try {
132
+ out[key] = decodeURIComponent(value);
133
+ }
134
+ catch {
135
+ out[key] = value;
136
+ }
137
+ }
138
+ return out;
139
+ }
140
+ /** Model identity an instrumented client propagated in trace `baggage`. `meta`
141
+ * is the request's `_meta` object (where MCP carries propagated context — see
142
+ * propagation.ts). Promotes OTel-canonical `gen_ai.request.model` /
143
+ * `gen_ai.provider.name` onto the span (the translator maps them to the
144
+ * model_id / model_provider columns). `{}` when baggage is absent or carries no
145
+ * model keys — the common case, since most third-party clients don't propagate
146
+ * this. Mirror of Python `baggage_model_attrs`. */
147
+ export function baggageModelAttrs(meta) {
148
+ if (meta === null || typeof meta !== 'object')
149
+ return {};
150
+ const raw = meta['baggage'];
151
+ if (typeof raw !== 'string')
152
+ return {};
153
+ const bag = parseBaggage(raw);
154
+ const attrs = {};
155
+ if (bag[BAGGAGE_MODEL_KEY])
156
+ attrs[BAGGAGE_MODEL_KEY] = bag[BAGGAGE_MODEL_KEY];
157
+ if (bag[BAGGAGE_PROVIDER_KEY])
158
+ attrs[BAGGAGE_PROVIDER_KEY] = bag[BAGGAGE_PROVIDER_KEY];
159
+ return attrs;
160
+ }
161
+ // ---------------------------------------------------------------------------
162
+ // Privacy-safe shape signals (capture-liberally, T1/T2) — the TS mirror of
163
+ // Python capture.py. Keys, counts, types, presence/length; NEVER values. They
164
+ // land in raw_attributes; `JSON.stringify` is compact, byte-matching Python's
165
+ // `_compact` (separators without spaces) so a TS- and a Python-emitted row for
166
+ // the same call are identical. Sorted arrays match Python's `sorted`.
167
+ // ---------------------------------------------------------------------------
168
+ const SERVER_CAP_FIELDS = ['experimental', 'logging', 'prompts', 'resources', 'tools', 'completions', 'tasks'];
169
+ const CLIENT_CAP_FIELDS = ['experimental', 'sampling', 'elicitation', 'roots', 'tasks'];
170
+ function offeredCapabilities(caps, fields) {
171
+ const out = [];
172
+ for (const f of fields) {
173
+ const v = caps == null ? undefined : caps[f];
174
+ if (v !== undefined && v !== null)
175
+ out.push(f);
176
+ }
177
+ return out.sort();
178
+ }
179
+ /** Shape of a tools/call result, without its content: the MCP `isError` flag
180
+ * (tool-reported failure, distinct from a transport/protocol error), whether
181
+ * `structuredContent` was returned, and the set of content block types. `{}`
182
+ * for anything that isn't tool-result-shaped. */
183
+ export function resultShapeAttrs(result) {
184
+ const isObj = result !== null && typeof result === 'object';
185
+ const hasContent = isObj && 'content' in result;
186
+ const hasIsError = isObj && 'isError' in result;
187
+ if (!(hasContent || hasIsError))
188
+ return {};
189
+ const content = Array.isArray(result.content) ? result.content : [];
190
+ const types = Array.from(new Set(content.map((c) => c?.type).filter((t) => t !== null && t !== undefined))).sort();
191
+ return {
192
+ 'mcp.tool.is_error': Boolean(result.isError),
193
+ 'mcp.tool.result.structured': result.structuredContent !== null && result.structuredContent !== undefined,
194
+ 'mcp.tool.result.content_count': content.length,
195
+ 'mcp.tool.result.content_types': JSON.stringify(types),
196
+ };
197
+ }
198
+ /** Privacy-safe summary of a call's arguments: sorted top-level keys, count,
199
+ * and emptiness. Keys only — never values. Non-object args yield `{}`. */
200
+ export function argumentShapeAttrs(args) {
201
+ if (args === null || args === undefined) {
202
+ return { 'vesta.args.keys': JSON.stringify([]), 'vesta.args.count': 0, 'vesta.args.empty': true };
203
+ }
204
+ if (typeof args !== 'object' || Array.isArray(args))
205
+ return {};
206
+ const keys = Object.keys(args).map(String).sort();
207
+ return {
208
+ 'vesta.args.keys': JSON.stringify(keys),
209
+ 'vesta.args.count': keys.length,
210
+ 'vesta.args.empty': keys.length === 0,
211
+ };
212
+ }
213
+ /** The negotiated contract from initialize: which capabilities each side
214
+ * advertised, and whether the server shipped natural-language `instructions`
215
+ * (its 'how to use me' to agents — an editable lever). The text is already in
216
+ * the (redaction-bypassed) initialize payload, so only presence + length here. */
217
+ export function handshakeContractAttrs(params, result) {
218
+ const attrs = {
219
+ 'mcp.client.capabilities': JSON.stringify(offeredCapabilities(params?.capabilities, CLIENT_CAP_FIELDS)),
220
+ 'mcp.server.capabilities': JSON.stringify(offeredCapabilities(result?.capabilities, SERVER_CAP_FIELDS)),
221
+ };
222
+ const instructions = result?.instructions;
223
+ attrs['mcp.server.instructions.present'] = Boolean(instructions);
224
+ if (instructions)
225
+ attrs['mcp.server.instructions.length'] = String(instructions).length;
226
+ return attrs;
227
+ }
228
+ // Must match Python `_hash_subject` byte-for-byte so the same OAuth subject maps
229
+ // to the same pseudonymous user_id across SDKs. The no-issuer form is unchanged;
230
+ // the issuer-qualified form binds (iss, sub) with a unit-separator that cannot
231
+ // appear in an issuer URL or opaque subject. Secret-keyed HMAC is a deliberate
232
+ // follow-up (see playbook/research/agent-session-identification.md) — it needs a
233
+ // process-stable per-customer key a random salt cannot provide.
234
+ const USER_HASH_NAMESPACE = 'vesta:user:';
235
+ const ISS_SUB_SEP = '\x1f';
236
+ function hashSubject(sub, iss) {
237
+ const material = iss ? `${iss}${ISS_SUB_SEP}${sub}` : sub;
238
+ return 'u_' + createHash('sha256').update(USER_HASH_NAMESPACE + material).digest('hex').slice(0, 24);
239
+ }
240
+ /** Server-side identity from the request's validated access token (TS SDK:
241
+ * `extra.authInfo`). Where the verifier exposes a subject claim, emit a
242
+ * pseudonymous, issuer-qualified `user_id = hash(iss, sub)` (never the raw
243
+ * subject, never the token). Always capture `scopes` + `client_id` when present.
244
+ * No subject → no user id. A service/client-credentials token frequently carries
245
+ * `sub === client_id` (no human principal); don't mint a "user" from an app
246
+ * identity — emit a user id only for a subject distinct from the client. */
247
+ export function authIdentityAttrs(authInfo) {
248
+ if (authInfo === null || authInfo === undefined)
249
+ return {};
250
+ const attrs = {};
251
+ // The official TS AuthInfo carries verifier claims under `extra`; some
252
+ // verifiers expose a `claims` object. Check both for `sub` and `iss`.
253
+ const sub = authInfo.extra?.sub ?? authInfo.claims?.sub ?? authInfo.extra?.claims?.sub;
254
+ const iss = authInfo.extra?.iss ?? authInfo.claims?.iss ?? authInfo.extra?.claims?.iss;
255
+ const clientId = authInfo.clientId ?? authInfo.client_id;
256
+ if (sub && sub !== clientId) {
257
+ attrs['vesta.dim.user_id'] = hashSubject(String(sub), iss ? String(iss) : null);
258
+ }
259
+ const scopes = authInfo.scopes;
260
+ if (Array.isArray(scopes) && scopes.length > 0) {
261
+ attrs['vesta.auth.scopes'] = JSON.stringify(scopes.map(String).sort());
262
+ }
263
+ if (clientId)
264
+ attrs['vesta.auth.client_id'] = String(clientId);
265
+ return attrs;
266
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Silent-by-default internal logger with a verbose toggle.
3
+ *
4
+ * Mirrors the Python SDK's diagnostics: warnings always surface (they are
5
+ * meant to be seen — "already instrumented", "circuit opened", "exporter
6
+ * setup failed"); debug is suppressed unless verbose. The OpenTelemetry JS
7
+ * `diag` API is a no-op by default, so OTel export noise stays silent until
8
+ * `verbose` wires a console diag logger — the analog of the Python SDK
9
+ * silencing the `opentelemetry` logger family.
10
+ */
11
+ export interface SdkLogger {
12
+ debug(...args: unknown[]): void;
13
+ warn(...args: unknown[]): void;
14
+ }
15
+ export declare function setVerbose(on: boolean): void;
16
+ export declare function isVerbose(): boolean;
17
+ export declare function sdkLogger(name: string): SdkLogger;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Silent-by-default internal logger with a verbose toggle.
3
+ *
4
+ * Mirrors the Python SDK's diagnostics: warnings always surface (they are
5
+ * meant to be seen — "already instrumented", "circuit opened", "exporter
6
+ * setup failed"); debug is suppressed unless verbose. The OpenTelemetry JS
7
+ * `diag` API is a no-op by default, so OTel export noise stays silent until
8
+ * `verbose` wires a console diag logger — the analog of the Python SDK
9
+ * silencing the `opentelemetry` logger family.
10
+ */
11
+ import { DiagConsoleLogger, DiagLogLevel, diag } from '@opentelemetry/api';
12
+ let _verbose = false;
13
+ export function setVerbose(on) {
14
+ _verbose = on;
15
+ if (on) {
16
+ // Restore OTel diagnostics so export failures surface (analog of
17
+ // silence_third_party(on_silent=False) in the Python SDK).
18
+ diag.setLogger(new DiagConsoleLogger(), DiagLogLevel.DEBUG);
19
+ }
20
+ else {
21
+ diag.disable();
22
+ }
23
+ }
24
+ export function isVerbose() {
25
+ return _verbose;
26
+ }
27
+ export function sdkLogger(name) {
28
+ const prefix = `[vesta:${name}]`;
29
+ return {
30
+ debug(...args) {
31
+ if (_verbose) {
32
+ // eslint-disable-next-line no-console
33
+ console.error(prefix, ...args);
34
+ }
35
+ },
36
+ warn(...args) {
37
+ // eslint-disable-next-line no-console
38
+ console.warn(prefix, ...args);
39
+ },
40
+ };
41
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * `sessionContext` closure resolution. Session scope only.
3
+ *
4
+ * Called once per session id, result cached. Fail-open: any exception in the
5
+ * customer's closure drops dimensions for that session and is logged
6
+ * internally — the customer's request is never affected.
7
+ */
8
+ export type SessionContext = (request: unknown) => Record<string, unknown>;
9
+ export declare class DimensionResolver {
10
+ private readonly closure;
11
+ private readonly cache;
12
+ constructor(closure: SessionContext | undefined);
13
+ forSession(sessionId: string | null, opts: {
14
+ request: unknown;
15
+ }): Record<string, unknown>;
16
+ private resolve;
17
+ }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * `sessionContext` closure resolution. Session scope only.
3
+ *
4
+ * Called once per session id, result cached. Fail-open: any exception in the
5
+ * customer's closure drops dimensions for that session and is logged
6
+ * internally — the customer's request is never affected.
7
+ */
8
+ import { sdkLogger } from './diagnostics.js';
9
+ const log = sdkLogger('dimensions');
10
+ export class DimensionResolver {
11
+ closure;
12
+ cache = new Map();
13
+ constructor(closure) {
14
+ this.closure = closure;
15
+ }
16
+ forSession(sessionId, opts) {
17
+ // Privacy invariant: a falsy/null session id means no stable per-session
18
+ // identity is available. Never share a cache entry in that case — doing so
19
+ // would leak one user's dimensions into an unrelated user's spans. Resolve
20
+ // fresh every call instead.
21
+ if (!sessionId) {
22
+ return this.resolve(opts.request);
23
+ }
24
+ const cached = this.cache.get(sessionId);
25
+ if (cached !== undefined)
26
+ return cached;
27
+ const resolved = this.resolve(opts.request);
28
+ this.cache.set(sessionId, resolved);
29
+ return resolved;
30
+ }
31
+ resolve(request) {
32
+ if (this.closure === undefined)
33
+ return {};
34
+ let raw;
35
+ try {
36
+ raw = this.closure(request);
37
+ }
38
+ catch {
39
+ log.debug('session_context closure raised; dropping dimensions');
40
+ return {};
41
+ }
42
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
43
+ log.debug('session_context returned non-object; dropping');
44
+ return {};
45
+ }
46
+ const out = {};
47
+ for (const [k, v] of Object.entries(raw)) {
48
+ if (v === null ||
49
+ v === undefined ||
50
+ typeof v === 'string' ||
51
+ typeof v === 'number' ||
52
+ typeof v === 'boolean') {
53
+ out[String(k)] = v ?? null;
54
+ }
55
+ else {
56
+ out[String(k)] = String(v).replace(/\n/g, ' ');
57
+ log.debug(`coerced non-scalar dimension ${k} to str`);
58
+ }
59
+ }
60
+ return out;
61
+ }
62
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * OTel-SDK export wiring + Vesta reliability primitives.
3
+ *
4
+ * Async ship-and-forget via BatchSpanProcessor. The customer's handler never
5
+ * waits on Vesta and never sees a Vesta exception. The circuit breaker drops
6
+ * silently after consecutive export failures so a dead ingest endpoint can't
7
+ * back up unbounded work in the customer's process.
8
+ */
9
+ import { BasicTracerProvider } from '@opentelemetry/sdk-trace-base';
10
+ export interface CircuitBreakerOptions {
11
+ threshold?: number;
12
+ cooldownS?: number;
13
+ now?: () => number;
14
+ }
15
+ export declare class CircuitBreaker {
16
+ private readonly threshold;
17
+ private readonly cooldown;
18
+ private readonly now;
19
+ private failures;
20
+ private openedAt;
21
+ constructor(opts?: CircuitBreakerOptions);
22
+ recordFailure(): void;
23
+ recordSuccess(): void;
24
+ isOpen(): boolean;
25
+ }
26
+ export interface BuildProviderOptions {
27
+ endpoint: string;
28
+ apiKey: string;
29
+ resourceAttrs: Record<string, string>;
30
+ maxQueueSize?: number;
31
+ maxExportBatchSize?: number;
32
+ scheduleDelayMs?: number;
33
+ }
34
+ /**
35
+ * Standard OTel exporter, gzip protobuf, bounded queue. BatchSpanProcessor's
36
+ * queue is hard-capped (maxQueueSize); when full it drops newest spans rather
37
+ * than growing unbounded — the bounded-memory guarantee carried over from the
38
+ * Python SDK.
39
+ */
40
+ export declare function buildTracerProvider(opts: BuildProviderOptions): BasicTracerProvider;
package/dist/export.js ADDED
@@ -0,0 +1,68 @@
1
+ /**
2
+ * OTel-SDK export wiring + Vesta reliability primitives.
3
+ *
4
+ * Async ship-and-forget via BatchSpanProcessor. The customer's handler never
5
+ * waits on Vesta and never sees a Vesta exception. The circuit breaker drops
6
+ * silently after consecutive export failures so a dead ingest endpoint can't
7
+ * back up unbounded work in the customer's process.
8
+ */
9
+ import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto';
10
+ import { Resource } from '@opentelemetry/resources';
11
+ import { BasicTracerProvider, BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
12
+ import { sdkLogger } from './diagnostics.js';
13
+ const log = sdkLogger('export');
14
+ export class CircuitBreaker {
15
+ threshold;
16
+ cooldown;
17
+ now;
18
+ failures = 0;
19
+ openedAt = null;
20
+ constructor(opts = {}) {
21
+ this.threshold = opts.threshold ?? 10;
22
+ this.cooldown = opts.cooldownS ?? 60.0;
23
+ this.now = opts.now ?? (() => Date.now() / 1000);
24
+ }
25
+ recordFailure() {
26
+ this.failures += 1;
27
+ if (this.failures >= this.threshold && this.openedAt === null) {
28
+ this.openedAt = this.now();
29
+ log.warn(`export circuit opened after ${this.failures} failures`);
30
+ }
31
+ }
32
+ recordSuccess() {
33
+ this.failures = 0;
34
+ this.openedAt = null;
35
+ }
36
+ isOpen() {
37
+ if (this.openedAt === null)
38
+ return false;
39
+ if (this.now() - this.openedAt >= this.cooldown) {
40
+ this.failures = 0;
41
+ this.openedAt = null;
42
+ return false;
43
+ }
44
+ return true;
45
+ }
46
+ }
47
+ /**
48
+ * Standard OTel exporter, gzip protobuf, bounded queue. BatchSpanProcessor's
49
+ * queue is hard-capped (maxQueueSize); when full it drops newest spans rather
50
+ * than growing unbounded — the bounded-memory guarantee carried over from the
51
+ * Python SDK.
52
+ */
53
+ export function buildTracerProvider(opts) {
54
+ const exporter = new OTLPTraceExporter({
55
+ url: opts.endpoint,
56
+ headers: { Authorization: `Bearer ${opts.apiKey}` },
57
+ });
58
+ const processor = new BatchSpanProcessor(exporter, {
59
+ maxQueueSize: opts.maxQueueSize ?? 2048,
60
+ maxExportBatchSize: opts.maxExportBatchSize ?? 100,
61
+ scheduledDelayMillis: opts.scheduleDelayMs ?? 5000,
62
+ });
63
+ const provider = new BasicTracerProvider({
64
+ resource: new Resource(opts.resourceAttrs),
65
+ });
66
+ provider.addSpanProcessor(processor);
67
+ return provider;
68
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * @vesta-analytics/sdk — instrument an MCP server with one call.
3
+ *
4
+ * ```ts
5
+ * import { instrument } from '@vesta-analytics/sdk';
6
+ * instrument(server, { apiKey: 'vsk_...' });
7
+ * ```
8
+ *
9
+ * Runs inside the customer's process — minimal footprint, fail-open, never
10
+ * breaks the host server. Ships behavioural data to Vesta over OTLP/HTTP, the
11
+ * same wire format as the Python SDK.
12
+ */
13
+ export declare const version = "0.0.1";
14
+ export { instrument } from './instrument.js';
15
+ export type { InstrumentOptions } from './instrument.js';
16
+ export type { InstrumentHandle } from './lifecycle.js';
17
+ export { RedactionConfig, Field, Tool, Prompt, Resource, Method } from './redaction.js';
18
+ export type { ActionName, Rule, Predicate, RedactionConfigInit } from './redaction.js';
19
+ export type { SessionContext } from './dimensions.js';
package/dist/index.js ADDED
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @vesta-analytics/sdk — instrument an MCP server with one call.
3
+ *
4
+ * ```ts
5
+ * import { instrument } from '@vesta-analytics/sdk';
6
+ * instrument(server, { apiKey: 'vsk_...' });
7
+ * ```
8
+ *
9
+ * Runs inside the customer's process — minimal footprint, fail-open, never
10
+ * breaks the host server. Ships behavioural data to Vesta over OTLP/HTTP, the
11
+ * same wire format as the Python SDK.
12
+ */
13
+ export const version = '0.0.1';
14
+ export { instrument } from './instrument.js';
15
+ export { RedactionConfig, Field, Tool, Prompt, Resource, Method } from './redaction.js';