sebbi 1.2.1 → 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 +19 -0
- package/index.cjs +10 -2
- package/otel.cjs +198 -0
- package/otel.d.ts +35 -0
- package/otel.mjs +4 -0
- package/package.json +18 -3
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.
|
|
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
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sebbi",
|
|
3
|
-
"version": "1.
|
|
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>",
|
|
@@ -63,5 +75,8 @@
|
|
|
63
75
|
},
|
|
64
76
|
"bugs": {
|
|
65
77
|
"url": "https://github.com/justrightdecorators-ops/sebbi-sdk/issues"
|
|
78
|
+
},
|
|
79
|
+
"scripts": {
|
|
80
|
+
"test": "node --test test/*.test.cjs"
|
|
66
81
|
}
|
|
67
82
|
}
|