sebbi 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -64,6 +64,25 @@ await r.wait(); // optional
64
64
  console.log(r.url, r.sealed, r.blockIndex);
65
65
  ```
66
66
 
67
+ ## OpenTelemetry
68
+
69
+ Already tracing? Add the processor and every agent run and tool call you trace gets a receipt, with the link written onto the span (`sebbi.receipt.id`, `sebbi.receipt.url`).
70
+
71
+ ```js
72
+ import { NodeSDK } from "@opentelemetry/sdk-node";
73
+ import { SebbiSpanProcessor } from "sebbi/otel";
74
+
75
+ new NodeSDK({ spanProcessors: [new SebbiSpanProcessor(), /* your exporter's processor */] }).start();
76
+ ```
77
+
78
+ **Vercel AI SDK:** turn on telemetry and every `ai.toolCall` and `ai.generateText` span is sealed:
79
+
80
+ ```js
81
+ await generateText({ model, tools, prompt, experimental_telemetry: { isEnabled: true } });
82
+ ```
83
+
84
+ Also recognised: OpenTelemetry GenAI conventions (`gen_ai.operation.name`), OpenInference (`openinference.span.kind`) and any span with `sebbi.seal = true`. Options: `{ llm, allSpans, filter, prefix, onReceipt }`. `SebbiSpanExporter` does the same inside an existing `BatchSpanProcessor`. Only two fingerprints per span leave your process. Fingerprints match the Python client exactly, so `sebbi verify-otlp traces.json` checks spans from either.
85
+
67
86
  ## Reversing an action
68
87
 
69
88
  ```js
package/index.cjs CHANGED
@@ -15,7 +15,7 @@
15
15
  * Zero dependencies. Node 18+, Deno, Bun, edge runtimes and browsers.
16
16
  */
17
17
 
18
- const VERSION = "1.2.0";
18
+ const VERSION = "1.3.0";
19
19
  const SITE = "https://sebbi.pro";
20
20
  const env = (k) => (typeof process !== "undefined" && process.env ? process.env[k] || "" : "");
21
21
 
@@ -205,6 +205,14 @@ function seal(input, output, label = "", outcome = "ok") {
205
205
  return p;
206
206
  }
207
207
 
208
+ /** Internal: seal already-computed fingerprints, optionally under a chosen id (used by sebbi/otel). */
209
+ function _sealHashes(inputHash, outputHash, label = "", outcome = "ok", id = null) {
210
+ const r = new Receipt(label, inputHash, outputHash, outcome);
211
+ if (id) r.id = id;
212
+ lastP = Promise.resolve(r);
213
+ return enqueue(r);
214
+ }
215
+
208
216
  /**
209
217
  * Reverse an action you sealed earlier. Nothing is deleted: the original stays
210
218
  * sealed and is shown as REVERSED, linked to the new reversal receipt. The
@@ -290,4 +298,4 @@ if (typeof process !== "undefined" && typeof process.on === "function") {
290
298
  process.on("beforeExit", () => { if (queue.length) kick(); });
291
299
  }
292
300
 
293
- module.exports = { receipt, seal, reverse, wrapTools, last, flush, configure, stats, fingerprint, check, Receipt, VERSION };
301
+ module.exports = { receipt, seal, reverse, wrapTools, last, flush, configure, stats, fingerprint, check, Receipt, VERSION, _sealHashes };
package/otel.cjs ADDED
@@ -0,0 +1,198 @@
1
+ "use strict";
2
+ /*
3
+ * sebbi for OpenTelemetry: a sealed receipt for every agent and tool span you
4
+ * already trace, with the receipt link written onto the span itself.
5
+ *
6
+ * const { SebbiSpanProcessor } = require("sebbi/otel");
7
+ * new NodeSDK({ spanProcessors: [new SebbiSpanProcessor(), ...yourProcessors] }).start();
8
+ *
9
+ * Vercel AI SDK: turn on experimental_telemetry: { isEnabled: true } and every
10
+ * ai.toolCall / ai.generateText span gets a receipt. Also recognised: the
11
+ * OpenTelemetry GenAI conventions (gen_ai.operation.name), OpenInference
12
+ * (openinference.span.kind) and any span with attribute sebbi.seal = true.
13
+ *
14
+ * Only two SHA-256 fingerprints, a short label and a timestamp leave your
15
+ * process. Fingerprints follow the same rules as the Python client, so
16
+ * `sebbi verify-otlp traces.json` checks spans from either.
17
+ */
18
+ const core = require("./index.cjs");
19
+
20
+ const ATTR_ID = "sebbi.receipt.id";
21
+ const ATTR_URL = "sebbi.receipt.url";
22
+ const ATTR_MARK = "sebbi.seal";
23
+ const SPEC = "sebbi-otel-span/1";
24
+ const SITE = "https://sebbi.pro";
25
+
26
+ const OUTPUT_PREFIXES = [
27
+ "gen_ai.output.", "gen_ai.completion", "gen_ai.response.", "gen_ai.usage.", "gen_ai.tool.call.result",
28
+ "output.", "llm.output_messages", "llm.token_count.",
29
+ "ai.response.", "ai.toolCall.result", "ai.result.", "ai.usage.",
30
+ ];
31
+ const GENAI = { execute_tool: "tool", invoke_agent: "agent", create_agent: "agent", chat: "llm", text_completion: "llm", generate_content: "llm" };
32
+ const OPENINF = { TOOL: "tool", AGENT: "agent", LLM: "llm" };
33
+ const VERCEL_AGENT = ["ai.generateText", "ai.streamText", "ai.generateObject", "ai.streamObject"];
34
+
35
+ function spanKind(name, attributes) {
36
+ const a = attributes || {};
37
+ if ([true, "true", "1", 1].includes(a[ATTR_MARK])) return "marked";
38
+ const op = a["gen_ai.operation.name"];
39
+ if (typeof op === "string" && GENAI[op]) return GENAI[op];
40
+ const oi = a["openinference.span.kind"];
41
+ if (typeof oi === "string" && OPENINF[oi.toUpperCase()]) return OPENINF[oi.toUpperCase()];
42
+ const vid = a["ai.operationId"] || name || "";
43
+ if (vid === "ai.toolCall") return "tool";
44
+ if (VERCEL_AGENT.includes(vid)) return "agent";
45
+ if (vid.startsWith("ai.") && (vid.endsWith(".doGenerate") || vid.endsWith(".doStream"))) return "llm";
46
+ return null;
47
+ }
48
+
49
+ function spanLabel(name, attributes, kind, prefix = "") {
50
+ const a = attributes || {};
51
+ const first = (...keys) => { for (const k of keys) if (typeof a[k] === "string" && a[k]) return a[k]; return name || "span"; };
52
+ let s;
53
+ if (kind === "tool") s = "tool:" + first("gen_ai.tool.name", "tool.name", "ai.toolCall.name");
54
+ else if (kind === "agent") s = "agent:" + first("gen_ai.agent.name", "agent.name", "ai.telemetry.functionId");
55
+ else if (kind === "llm") s = "llm:" + first("gen_ai.request.model", "llm.model_name", "ai.model.id");
56
+ else s = name || "span";
57
+ return (prefix + s).slice(0, 80);
58
+ }
59
+
60
+ // Numbers, written the same way in Python and JavaScript.
61
+ function num(x) {
62
+ if (!Number.isFinite(x)) return Number.isNaN(x) ? "NaN" : x > 0 ? "Infinity" : "-Infinity";
63
+ if (Number.isInteger(x)) return Math.abs(x) < 2 ** 53 ? (x === 0 ? 0 : x) : String(x);
64
+ return String(x);
65
+ }
66
+ function val(v) {
67
+ if (Array.isArray(v)) return v.map(val);
68
+ if (typeof v === "number") return num(v);
69
+ if (v === undefined || v === null) return null;
70
+ if (typeof v === "string" || typeof v === "boolean") return v;
71
+ return String(v);
72
+ }
73
+ function attrs(a, wantOutput) {
74
+ const out = {};
75
+ for (const k of Object.keys(a || {})) {
76
+ if (k.startsWith("sebbi.")) continue;
77
+ if (OUTPUT_PREFIXES.some((p) => k.startsWith(p)) === wantOutput) out[k] = val(a[k]);
78
+ }
79
+ return out;
80
+ }
81
+ function nanos(t) {
82
+ if (Array.isArray(t)) return (BigInt(t[0]) * 1000000000n + BigInt(t[1])).toString();
83
+ if (typeof t === "bigint") return t.toString();
84
+ if (typeof t === "string") return BigInt(t || "0").toString();
85
+ return BigInt(Math.trunc(Number(t) || 0)).toString();
86
+ }
87
+ const statusName = (c) => (typeof c === "number" ? { 0: "UNSET", 1: "OK", 2: "ERROR" }[c] || "UNSET"
88
+ : (["UNSET", "OK", "ERROR"].includes(String(c || "").toUpperCase().replace("STATUS_CODE_", "")) ? String(c).toUpperCase().replace("STATUS_CODE_", "") : "UNSET"));
89
+
90
+ /** The two documents a span's receipt fingerprints. Same rules in every sebbi client. */
91
+ function buildPayload(p) {
92
+ const input = {
93
+ spec: SPEC,
94
+ name: p.name || "",
95
+ trace_id: String(p.traceId || "").toLowerCase(),
96
+ span_id: String(p.spanId || "").toLowerCase(),
97
+ parent_span_id: String(p.parentSpanId || "").toLowerCase() || null,
98
+ start_time_unix_nano: nanos(p.start || 0),
99
+ attributes: attrs(p.attributes, false),
100
+ };
101
+ const output = {
102
+ end_time_unix_nano: nanos(p.end || 0),
103
+ status: statusName(p.statusCode || 0),
104
+ status_message: p.statusMessage || "",
105
+ attributes: attrs(p.attributes, true),
106
+ events: (p.events || []).map((e) => ({ name: e.name || "", time_unix_nano: nanos(e.time || 0),
107
+ attributes: Object.assign(attrs(e.attributes, false), attrs(e.attributes, true)) })),
108
+ };
109
+ return { input, output };
110
+ }
111
+
112
+ /** { input, output } documents for a finished OpenTelemetry JS span. */
113
+ function spanPayload(span) {
114
+ const ctx = typeof span.spanContext === "function" ? span.spanContext() : span._spanContext || {};
115
+ const parent = (span.parentSpanContext && span.parentSpanContext.spanId) || span.parentSpanId || null;
116
+ return buildPayload({
117
+ name: span.name, traceId: ctx.traceId, spanId: ctx.spanId, parentSpanId: parent,
118
+ start: span.startTime, end: span.endTime, attributes: span.attributes,
119
+ statusCode: span.status ? span.status.code : 0, statusMessage: span.status ? span.status.message : "",
120
+ events: (span.events || []).map((e) => ({ name: e.name, time: e.time, attributes: e.attributes })),
121
+ });
122
+ }
123
+
124
+ async function spanFingerprints(span) {
125
+ const { input, output } = spanPayload(span);
126
+ return { inputHash: await core.fingerprint(input), outputHash: await core.fingerprint(output) };
127
+ }
128
+
129
+ function policy(o = {}) {
130
+ return (span) => {
131
+ const kind = spanKind(span.name, span.attributes);
132
+ if (typeof o.filter === "function") { try { return o.filter(span) ? kind || "span" : null; } catch (_) { return null; } }
133
+ if (o.allSpans) return kind || "span";
134
+ if (kind === "tool" || kind === "agent" || kind === "marked" || (kind === "llm" && o.llm)) return kind;
135
+ return null;
136
+ };
137
+ }
138
+
139
+ const newId = () => {
140
+ const b = globalThis.crypto ? globalThis.crypto.getRandomValues(new Uint8Array(12)) : require("node:crypto").randomBytes(12);
141
+ return "r" + Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("").slice(0, 23);
142
+ };
143
+
144
+ /**
145
+ * Seals agent and tool spans as they end, and stamps each with its receipt
146
+ * link (sebbi.receipt.id / sebbi.receipt.url) when it starts.
147
+ * Options: { llm, allSpans, filter(span), prefix, onReceipt(span, receipt) }
148
+ */
149
+ class SebbiSpanProcessor {
150
+ constructor(options = {}) {
151
+ this._o = options;
152
+ this._wants = policy(options);
153
+ this._pending = new Set();
154
+ }
155
+ onStart(span) {
156
+ try {
157
+ if (this._wants(span)) {
158
+ const id = newId();
159
+ span.setAttribute(ATTR_ID, id);
160
+ span.setAttribute(ATTR_URL, SITE + "/r/" + id);
161
+ }
162
+ } catch (_) {}
163
+ }
164
+ onEnd(span) {
165
+ let kind;
166
+ try { kind = this._wants(span); } catch (_) { return; }
167
+ if (!kind) return;
168
+ const p = (async () => {
169
+ const { inputHash, outputHash } = await spanFingerprints(span);
170
+ const id = span.attributes && typeof span.attributes[ATTR_ID] === "string" ? span.attributes[ATTR_ID] : null;
171
+ const outcome = statusName(span.status ? span.status.code : 0) === "ERROR" ? "error" : "ok";
172
+ const r = core._sealHashes(inputHash, outputHash, spanLabel(span.name, span.attributes, kind, this._o.prefix || ""), outcome, id);
173
+ if (typeof this._o.onReceipt === "function") this._o.onReceipt(span, r);
174
+ return r;
175
+ })().catch(() => null).finally(() => this._pending.delete(p));
176
+ this._pending.add(p);
177
+ }
178
+ async forceFlush() { await Promise.all([...this._pending]); await core.flush(30000); }
179
+ async shutdown() { await this.forceFlush(); }
180
+ }
181
+
182
+ /**
183
+ * The same sealing as a SpanExporter, for pipelines that already batch spans:
184
+ * new BatchSpanProcessor(new SebbiSpanExporter())
185
+ * Exporters see spans after they end, so the link is not written onto the
186
+ * span; use SebbiSpanProcessor for that. Always reports success.
187
+ */
188
+ class SebbiSpanExporter {
189
+ constructor(options = {}) { this._proc = new SebbiSpanProcessor(options); }
190
+ export(spans, resultCallback) {
191
+ for (const s of spans || []) this._proc.onEnd(s);
192
+ resultCallback({ code: 0 });
193
+ }
194
+ forceFlush() { return this._proc.forceFlush(); }
195
+ shutdown() { return this._proc.shutdown(); }
196
+ }
197
+
198
+ module.exports = { SebbiSpanProcessor, SebbiSpanExporter, spanKind, spanLabel, spanPayload, spanFingerprints, buildPayload, ATTR_ID, ATTR_URL };
package/otel.d.ts ADDED
@@ -0,0 +1,35 @@
1
+ import type { Receipt } from "./index";
2
+ export interface SebbiOtelOptions {
3
+ /** Also seal every model call (chat / doGenerate spans). */
4
+ llm?: boolean;
5
+ /** Seal every span. Watch your daily volume. */
6
+ allSpans?: boolean;
7
+ /** Your own rule, asked at span start and end; the answer at end decides. */
8
+ filter?: (span: any) => boolean;
9
+ /** Put in front of every receipt label. */
10
+ prefix?: string;
11
+ /** Called after each span is queued for sealing. */
12
+ onReceipt?: (span: any, receipt: Receipt) => void;
13
+ }
14
+ /** OpenTelemetry SpanProcessor: seals agent and tool spans and writes sebbi.receipt.id / sebbi.receipt.url onto them. */
15
+ export declare class SebbiSpanProcessor {
16
+ constructor(options?: SebbiOtelOptions);
17
+ onStart(span: any, parentContext?: any): void;
18
+ onEnd(span: any): void;
19
+ forceFlush(): Promise<void>;
20
+ shutdown(): Promise<void>;
21
+ }
22
+ /** OpenTelemetry SpanExporter with the same sealing (no link written onto the span). */
23
+ export declare class SebbiSpanExporter {
24
+ constructor(options?: SebbiOtelOptions);
25
+ export(spans: any[], resultCallback: (result: { code: number }) => void): void;
26
+ forceFlush(): Promise<void>;
27
+ shutdown(): Promise<void>;
28
+ }
29
+ export declare function spanKind(name: string, attributes?: Record<string, unknown>): "tool" | "agent" | "llm" | "marked" | null;
30
+ export declare function spanLabel(name: string, attributes: Record<string, unknown> | undefined, kind: string | null, prefix?: string): string;
31
+ export declare function spanPayload(span: any): { input: Record<string, unknown>; output: Record<string, unknown> };
32
+ export declare function spanFingerprints(span: any): Promise<{ inputHash: string; outputHash: string }>;
33
+ export declare function buildPayload(p: Record<string, unknown>): { input: Record<string, unknown>; output: Record<string, unknown> };
34
+ export declare const ATTR_ID: "sebbi.receipt.id";
35
+ export declare const ATTR_URL: "sebbi.receipt.url";
package/otel.mjs ADDED
@@ -0,0 +1,4 @@
1
+ // sebbi for OpenTelemetry (ESM). See otel.cjs for the full notes.
2
+ import m from "./otel.cjs";
3
+ export const { SebbiSpanProcessor, SebbiSpanExporter, spanKind, spanLabel, spanPayload, spanFingerprints, buildPayload, ATTR_ID, ATTR_URL } = m;
4
+ export default m;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "sebbi",
3
- "version": "1.2.0",
4
- "description": "A receipt for every AI agent action. One line: every tool call gets a sealed, publicly checkable receipt. Vercel AI SDK, LangChain.js, Mastra, MCP.",
3
+ "version": "1.3.0",
4
+ "description": "A receipt for every AI agent action. One line: every tool call gets a sealed, publicly checkable receipt. Vercel AI SDK, OpenTelemetry, LangChain.js, Mastra, MCP.",
5
5
  "main": "./index.cjs",
6
6
  "module": "./index.mjs",
7
7
  "types": "./index.d.ts",
@@ -14,6 +14,11 @@
14
14
  "./ai-sdk": {
15
15
  "types": "./ai-sdk.d.ts",
16
16
  "import": "./ai-sdk.mjs"
17
+ },
18
+ "./otel": {
19
+ "types": "./otel.d.ts",
20
+ "import": "./otel.mjs",
21
+ "require": "./otel.cjs"
17
22
  }
18
23
  },
19
24
  "files": [
@@ -22,6 +27,9 @@
22
27
  "index.d.ts",
23
28
  "ai-sdk.mjs",
24
29
  "ai-sdk.d.ts",
30
+ "otel.cjs",
31
+ "otel.mjs",
32
+ "otel.d.ts",
25
33
  "README.md",
26
34
  "LICENSE"
27
35
  ],
@@ -43,7 +51,11 @@
43
51
  "eu-ai-act",
44
52
  "compliance",
45
53
  "provenance",
46
- "merkle"
54
+ "merkle",
55
+ "opentelemetry",
56
+ "otel",
57
+ "tracing",
58
+ "observability"
47
59
  ],
48
60
  "homepage": "https://sebbi.pro/receipts",
49
61
  "author": "Monop Content <justrightdecorators@gmail.com>",
@@ -55,5 +67,16 @@
55
67
  "ai": {
56
68
  "optional": true
57
69
  }
70
+ },
71
+ "repository": {
72
+ "type": "git",
73
+ "url": "git+https://github.com/justrightdecorators-ops/sebbi-sdk.git",
74
+ "directory": "js"
75
+ },
76
+ "bugs": {
77
+ "url": "https://github.com/justrightdecorators-ops/sebbi-sdk/issues"
78
+ },
79
+ "scripts": {
80
+ "test": "node --test test/*.test.cjs"
58
81
  }
59
82
  }