@hashspan/core 0.0.0 → 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.
package/dist/index.mjs ADDED
@@ -0,0 +1,621 @@
1
+ import { SpanKind, SpanStatusCode, context, diag, propagation, trace } from "@opentelemetry/api";
2
+ //#region src/agent.ts
3
+ const ATTR_GEN_AI_AGENT_ID = "gen_ai.agent.id";
4
+ const ATTR_GEN_AI_AGENT_NAME = "gen_ai.agent.name";
5
+ /** Agent identity from baggage (preferred) or the static fallback, as GenAI attributes. */
6
+ function agentAttributes(ctx, fallback) {
7
+ const baggage = propagation.getBaggage(ctx);
8
+ const id = baggage?.getEntry("gen_ai.agent.id")?.value ?? fallback?.id;
9
+ const name = baggage?.getEntry("gen_ai.agent.name")?.value ?? fallback?.name;
10
+ const attributes = {};
11
+ if (id !== void 0) attributes[ATTR_GEN_AI_AGENT_ID] = id;
12
+ if (name !== void 0) attributes[ATTR_GEN_AI_AGENT_NAME] = name;
13
+ return attributes;
14
+ }
15
+ //#endregion
16
+ //#region src/attributes.ts
17
+ /**
18
+ * Attribute keys emitted by hashspan.
19
+ *
20
+ * Stability: development. See docs/semconv.md for definitions and value types.
21
+ * These names are a public contract: changes follow the deprecation policy in AGENTS.md.
22
+ */
23
+ const ATTR_BLOCKCHAIN_SYSTEM = "blockchain.system";
24
+ const ATTR_BLOCKCHAIN_CHAIN_ID = "blockchain.chain.id";
25
+ const ATTR_BLOCKCHAIN_OPERATION_NAME = "blockchain.operation.name";
26
+ const ATTR_BLOCKCHAIN_TX_HASH = "blockchain.tx.hash";
27
+ const ATTR_BLOCKCHAIN_TX_FROM = "blockchain.tx.from";
28
+ const ATTR_BLOCKCHAIN_TX_TO = "blockchain.tx.to";
29
+ const ATTR_BLOCKCHAIN_TX_VALUE = "blockchain.tx.value";
30
+ const ATTR_BLOCKCHAIN_TX_NONCE = "blockchain.tx.nonce";
31
+ const ATTR_BLOCKCHAIN_TX_STATUS = "blockchain.tx.status";
32
+ const ATTR_BLOCKCHAIN_TX_GAS_USED = "blockchain.tx.gas.used";
33
+ const ATTR_BLOCKCHAIN_TX_EFFECTIVE_GAS_PRICE = "blockchain.tx.effective_gas_price";
34
+ const ATTR_BLOCKCHAIN_TX_L1_FEE = "blockchain.tx.l1_fee";
35
+ const ATTR_BLOCKCHAIN_TX_FEE = "blockchain.tx.fee";
36
+ const ATTR_BLOCKCHAIN_TX_REVERT_REASON = "blockchain.tx.revert.reason";
37
+ const ATTR_BLOCKCHAIN_TX_REPLACEMENT_HASH = "blockchain.tx.replacement.hash";
38
+ const ATTR_BLOCKCHAIN_TX_REPLACEMENT_REASON = "blockchain.tx.replacement.reason";
39
+ const ATTR_BLOCKCHAIN_BLOCK_NUMBER = "blockchain.block.number";
40
+ const ATTR_BLOCKCHAIN_CONTRACT_FUNCTION_NAME = "blockchain.contract.function.name";
41
+ const ATTR_BLOCKCHAIN_CONTRACT_FUNCTION_SELECTOR = "blockchain.contract.function.selector";
42
+ /** Opt-in: decoded call arguments as a JSON array. See docs/adr/0004-privacy-defaults.md. */
43
+ const ATTR_BLOCKCHAIN_CONTRACT_FUNCTION_ARGUMENTS = "blockchain.contract.function.arguments";
44
+ /** Values for {@link ATTR_BLOCKCHAIN_SYSTEM}. */
45
+ const BLOCKCHAIN_SYSTEM_VALUE_EVM = "evm";
46
+ /** Values for {@link ATTR_BLOCKCHAIN_OPERATION_NAME}. */
47
+ const BLOCKCHAIN_OPERATION_NAME_VALUE_SEND = "send";
48
+ const BLOCKCHAIN_OPERATION_NAME_VALUE_CONFIRM = "confirm";
49
+ /** Values for {@link ATTR_BLOCKCHAIN_TX_STATUS}. */
50
+ const BLOCKCHAIN_TX_STATUS_VALUE_SUCCESS = "success";
51
+ const BLOCKCHAIN_TX_STATUS_VALUE_REVERTED = "reverted";
52
+ const BLOCKCHAIN_TX_STATUS_VALUE_TIMEOUT = "timeout";
53
+ const BLOCKCHAIN_TX_STATUS_VALUE_REPLACED = "replaced";
54
+ /** Values for {@link ATTR_BLOCKCHAIN_TX_REPLACEMENT_REASON}, as reported by the instrumented library. */
55
+ const BLOCKCHAIN_TX_REPLACEMENT_REASON_VALUE_REPRICED = "repriced";
56
+ const BLOCKCHAIN_TX_REPLACEMENT_REASON_VALUE_CANCELLED = "cancelled";
57
+ const BLOCKCHAIN_TX_REPLACEMENT_REASON_VALUE_REPLACED = "replaced";
58
+ /** Reused from OpenTelemetry general conventions. */
59
+ const ATTR_ERROR_TYPE = "error.type";
60
+ /** Fallback {@link ATTR_ERROR_TYPE} value when the error has no name. */
61
+ const ERROR_TYPE_VALUE_OTHER = "_OTHER";
62
+ //#endregion
63
+ //#region src/confirm-registry.ts
64
+ /**
65
+ * Bounded registry from (chainId, tx hash) to its in-flight confirm span, or to "settled" for a while after a
66
+ * receipt was recorded, so each transaction gets one confirm span per tracker.
67
+ */
68
+ var ConfirmRegistry = class ConfirmRegistry {
69
+ entries = /* @__PURE__ */ new Map();
70
+ ttlMs;
71
+ maxEntries;
72
+ constructor(options) {
73
+ this.ttlMs = options.ttlMs;
74
+ this.maxEntries = options.maxEntries;
75
+ }
76
+ /** The in-flight confirm span, `'settled'` if the transaction recently got a receipt, else undefined. */
77
+ get(chainId, hash) {
78
+ const key = ConfirmRegistry.key(chainId, hash);
79
+ const entry = this.entries.get(key);
80
+ if (!entry) return void 0;
81
+ if ("confirm" in entry) return entry.confirm;
82
+ if (entry.settledUntil > Date.now()) return "settled";
83
+ this.entries.delete(key);
84
+ }
85
+ start(chainId, hash, confirm) {
86
+ this.put(ConfirmRegistry.key(chainId, hash), { confirm });
87
+ }
88
+ /** Marks `confirm` as settled, unless the registry has since moved on from it. */
89
+ settle(chainId, hash, confirm) {
90
+ const key = ConfirmRegistry.key(chainId, hash);
91
+ if (this.isCurrent(key, confirm)) this.put(key, { settledUntil: Date.now() + this.ttlMs });
92
+ }
93
+ /** Forgets `confirm` so that a retry starts a new span, unless the registry has since moved on from it. */
94
+ release(chainId, hash, confirm) {
95
+ const key = ConfirmRegistry.key(chainId, hash);
96
+ if (this.isCurrent(key, confirm)) this.entries.delete(key);
97
+ }
98
+ isCurrent(key, confirm) {
99
+ const entry = this.entries.get(key);
100
+ return entry !== void 0 && "confirm" in entry && entry.confirm === confirm;
101
+ }
102
+ put(key, entry) {
103
+ this.entries.delete(key);
104
+ this.entries.set(key, entry);
105
+ for (const oldest of this.entries.keys()) {
106
+ if (this.entries.size <= this.maxEntries) break;
107
+ this.entries.delete(oldest);
108
+ }
109
+ }
110
+ static key(chainId, hash) {
111
+ return `${chainId}:${hash.toLowerCase()}`;
112
+ }
113
+ };
114
+ //#endregion
115
+ //#region src/link-store.ts
116
+ /** Bounded, TTL-evicted map from (chainId, tx hash) to the send span it came from. */
117
+ var LinkStore = class LinkStore {
118
+ entries = /* @__PURE__ */ new Map();
119
+ ttlMs;
120
+ maxEntries;
121
+ constructor(options) {
122
+ this.ttlMs = options.ttlMs;
123
+ this.maxEntries = options.maxEntries;
124
+ }
125
+ get size() {
126
+ return this.entries.size;
127
+ }
128
+ set(chainId, hash, value) {
129
+ const key = LinkStore.key(chainId, hash);
130
+ this.entries.delete(key);
131
+ this.entries.set(key, {
132
+ ...value,
133
+ expiresAt: Date.now() + this.ttlMs
134
+ });
135
+ for (const oldest of this.entries.keys()) {
136
+ if (this.entries.size <= this.maxEntries) break;
137
+ this.entries.delete(oldest);
138
+ }
139
+ }
140
+ get(chainId, hash) {
141
+ const key = LinkStore.key(chainId, hash);
142
+ const entry = this.entries.get(key);
143
+ if (!entry) return void 0;
144
+ if (entry.expiresAt <= Date.now()) {
145
+ this.entries.delete(key);
146
+ return;
147
+ }
148
+ return entry;
149
+ }
150
+ static key(chainId, hash) {
151
+ return `${chainId}:${hash.toLowerCase()}`;
152
+ }
153
+ };
154
+ //#endregion
155
+ //#region src/privacy.ts
156
+ const formatter = (format, protectsAddresses) => Object.assign(format, { protectsAddresses });
157
+ /** Records no addresses; the fallback whenever the address mode cannot be applied. */
158
+ const OFF_ADDRESS_FORMATTER = formatter(() => void 0, true);
159
+ /** Loads node:crypto lazily so the package stays importable in non-Node runtimes. */
160
+ function defaultHash() {
161
+ const getBuiltinModule = globalThis.process?.getBuiltinModule;
162
+ const crypto = getBuiltinModule?.("node:crypto");
163
+ if (!crypto) return void 0;
164
+ return (address) => `sha256:${crypto.createHash("sha256").update(address).digest("hex").slice(0, 32)}`;
165
+ }
166
+ function resolveAddressFormatter(option) {
167
+ const { mode, hash } = typeof option === "object" ? option : {
168
+ mode: option ?? "raw",
169
+ hash: void 0
170
+ };
171
+ switch (mode) {
172
+ case "raw": return formatter((address) => address, false);
173
+ case "off": return OFF_ADDRESS_FORMATTER;
174
+ case "hashed": {
175
+ const hashFn = hash ?? defaultHash();
176
+ if (!hashFn) {
177
+ diag.warn("hashspan: no SHA-256 available in this runtime; addresses will not be recorded");
178
+ return OFF_ADDRESS_FORMATTER;
179
+ }
180
+ return formatter((address) => hashFn(address.toLowerCase()), true);
181
+ }
182
+ default:
183
+ diag.warn(`hashspan: unknown address mode "${String(mode)}"; addresses will not be recorded`);
184
+ return OFF_ADDRESS_FORMATTER;
185
+ }
186
+ }
187
+ /** 0x-prefixed hex. Unprefixed hex and addresses written as numbers are not detected. */
188
+ const HEX = /0[xX][0-9a-fA-F]+/g;
189
+ const ADDRESS_LENGTH = 42;
190
+ /**
191
+ * Longest hex kept in sanitized error messages in raw address mode: a 32-byte word such as a transaction hash. In
192
+ * `off` and `hashed` mode, {@link formatAddressesIn} already replaces any hex longer than an address.
193
+ */
194
+ const MAX_HEX_LENGTH = 66;
195
+ const MAX_MESSAGE_LENGTH = 256;
196
+ /**
197
+ * Rewrites every address in `text` with the address mode (`<address>` when it records none). In `off` and `hashed`
198
+ * mode, hex values longer than an address become `<hex>`, because they can embed one.
199
+ */
200
+ function formatAddressesIn(text, formatAddress) {
201
+ return text.replace(HEX, (hex) => {
202
+ if (hex.length === ADDRESS_LENGTH) return formatAddress(hex) ?? "<address>";
203
+ return hex.length > ADDRESS_LENGTH && formatAddress.protectsAddresses ? "<hex>" : hex;
204
+ });
205
+ }
206
+ /**
207
+ * First line of an error message with addresses per address mode and longer hex data (such as calldata)
208
+ * replaced by `<hex>`. Best effort: other free text is kept, so the redaction hook still runs on the result.
209
+ */
210
+ function sanitizeErrorMessage(message, formatAddress) {
211
+ const sanitized = formatAddressesIn(message.split("\n", 1)[0]?.trim() ?? "", formatAddress).replace(HEX, (hex) => hex.length > MAX_HEX_LENGTH ? "<hex>" : hex);
212
+ return sanitized.length > MAX_MESSAGE_LENGTH ? `${sanitized.slice(0, MAX_MESSAGE_LENGTH)}...` : sanitized;
213
+ }
214
+ const MAX_ARGUMENTS_LENGTH = 4096;
215
+ const MAX_ARGUMENTS_DEPTH = 32;
216
+ var ArgumentsLimitReached = class extends Error {};
217
+ /**
218
+ * Call arguments as a JSON array: bigints as decimal strings, addresses per address mode (see
219
+ * {@link formatAddressesIn}), at most `MAX_ARGUMENTS_LENGTH` characters followed by `...`.
220
+ *
221
+ * Side-effect free for ordinary values: it reads only own enumerable data properties and never calls `toJSON()` or
222
+ * getters (so a `Date` records as `{}`). A Proxy's traps still run, as for any property read; use the redaction
223
+ * hook, or leave arguments off, for values that are Proxies. It stops after the value that crosses the length limit instead of walking the rest. Functions, symbols and `undefined` are skipped in objects and written as `null` in arrays, as in
224
+ * JSON. Throws for cycles and for nesting deeper than `MAX_ARGUMENTS_DEPTH`.
225
+ */
226
+ function serializeFunctionArguments(args, formatAddress) {
227
+ let out = "";
228
+ const write = (chunk) => {
229
+ out += chunk;
230
+ if (out.length > MAX_ARGUMENTS_LENGTH) throw new ArgumentsLimitReached();
231
+ };
232
+ const text = (value) => JSON.stringify(formatAddressesIn(value, formatAddress));
233
+ const ancestors = /* @__PURE__ */ new Set();
234
+ /** Writes `value`; returns false, writing nothing, when it has no JSON representation. */
235
+ const walk = (value, depth) => {
236
+ switch (typeof value) {
237
+ case "string":
238
+ write(text(value));
239
+ return true;
240
+ case "bigint":
241
+ write(`"${value.toString()}"`);
242
+ return true;
243
+ case "number":
244
+ write(Number.isFinite(value) ? String(value) : "null");
245
+ return true;
246
+ case "boolean":
247
+ write(value ? "true" : "false");
248
+ return true;
249
+ case "object": break;
250
+ default: return false;
251
+ }
252
+ if (value === null) {
253
+ write("null");
254
+ return true;
255
+ }
256
+ if (ancestors.has(value)) throw new TypeError("cyclic function arguments");
257
+ if (depth >= MAX_ARGUMENTS_DEPTH) throw new TypeError("function arguments nested too deeply");
258
+ ancestors.add(value);
259
+ const own = (key) => Object.getOwnPropertyDescriptor(value, key);
260
+ if (Array.isArray(value)) {
261
+ write("[");
262
+ const length = own("length")?.value;
263
+ for (let i = 0; i < (typeof length === "number" ? length : 0); i++) {
264
+ if (i > 0) write(",");
265
+ const element = own(String(i));
266
+ if (!element || !("value" in element) || !walk(element.value, depth + 1)) write("null");
267
+ }
268
+ write("]");
269
+ } else {
270
+ write("{");
271
+ let first = true;
272
+ for (const key of Object.keys(value)) {
273
+ const descriptor = own(key);
274
+ if (!descriptor || !("value" in descriptor)) continue;
275
+ const kind = typeof descriptor.value;
276
+ if (kind === "undefined" || kind === "function" || kind === "symbol") continue;
277
+ write(`${first ? "" : ","}${text(key)}:`);
278
+ first = false;
279
+ walk(descriptor.value, depth + 1);
280
+ }
281
+ write("}");
282
+ }
283
+ ancestors.delete(value);
284
+ return true;
285
+ };
286
+ try {
287
+ walk(args, 0);
288
+ return out;
289
+ } catch (error) {
290
+ if (error instanceof ArgumentsLimitReached) return `${out.slice(0, MAX_ARGUMENTS_LENGTH)}...`;
291
+ throw error;
292
+ }
293
+ }
294
+ function resolveErrorMessageMode(mode) {
295
+ if (mode === void 0 || mode === "off" || mode === "sanitized" || mode === "raw") return mode ?? "off";
296
+ diag.warn(`hashspan: unknown error message mode "${String(mode)}"; recording error types only`);
297
+ return "off";
298
+ }
299
+ //#endregion
300
+ //#region src/version.ts
301
+ const VERSION = "0.1.0";
302
+ //#endregion
303
+ //#region src/tracker.ts
304
+ const INSTRUMENTATION_NAME = "@hashspan/core";
305
+ const DEFAULT_LINK_TTL_MS = 6e5;
306
+ const DEFAULT_MAX_TRACKED = 1e4;
307
+ /** OpenTelemetry exception event and attributes. */
308
+ const EXCEPTION_EVENT = "exception";
309
+ const ATTR_EXCEPTION_TYPE = "exception.type";
310
+ const ATTR_EXCEPTION_MESSAGE = "exception.message";
311
+ const ATTR_EXCEPTION_STACKTRACE = "exception.stacktrace";
312
+ /** Attributes kept when the redaction hook fails (fail closed). */
313
+ const NON_SENSITIVE_KEYS = /* @__PURE__ */ new Set([
314
+ ATTR_BLOCKCHAIN_SYSTEM,
315
+ ATTR_BLOCKCHAIN_CHAIN_ID,
316
+ ATTR_BLOCKCHAIN_OPERATION_NAME,
317
+ ATTR_BLOCKCHAIN_TX_HASH,
318
+ ATTR_BLOCKCHAIN_TX_STATUS,
319
+ ATTR_BLOCKCHAIN_TX_REPLACEMENT_HASH,
320
+ ATTR_BLOCKCHAIN_TX_REPLACEMENT_REASON,
321
+ ATTR_ERROR_TYPE,
322
+ ATTR_EXCEPTION_TYPE
323
+ ]);
324
+ const TX_HASH = /^0x[0-9a-fA-F]{64}$/;
325
+ const REPLACEMENT_REASONS = /* @__PURE__ */ new Set([
326
+ BLOCKCHAIN_TX_REPLACEMENT_REASON_VALUE_REPRICED,
327
+ BLOCKCHAIN_TX_REPLACEMENT_REASON_VALUE_CANCELLED,
328
+ BLOCKCHAIN_TX_REPLACEMENT_REASON_VALUE_REPLACED
329
+ ]);
330
+ const NOOP_SEND = {
331
+ end: () => {},
332
+ fail: () => {}
333
+ };
334
+ const NOOP_CONFIRM = {
335
+ end: () => {},
336
+ timeout: () => {},
337
+ fail: () => {}
338
+ };
339
+ /** Runs `fn`, logging instead of throwing: instrumentation must never break the caller. */
340
+ function safely(what, fn, fallback) {
341
+ try {
342
+ return fn();
343
+ } catch (error) {
344
+ diag.error(`hashspan: failed to ${what}`, error);
345
+ return fallback;
346
+ }
347
+ }
348
+ function toInt(value) {
349
+ if (typeof value === "bigint") return Number(value);
350
+ if (typeof value === "number" && Number.isFinite(value)) return value;
351
+ throw new TypeError(`expected bigint or number, got ${typeof value}`);
352
+ }
353
+ function errorType(error) {
354
+ return error instanceof Error && error.name ? error.name : ERROR_TYPE_VALUE_OTHER;
355
+ }
356
+ function createTxTracker(options = {}) {
357
+ const links = new LinkStore({
358
+ ttlMs: options.linkTtlMs ?? DEFAULT_LINK_TTL_MS,
359
+ maxEntries: options.maxTrackedTransactions ?? DEFAULT_MAX_TRACKED
360
+ });
361
+ const confirmations = new ConfirmRegistry({
362
+ ttlMs: options.linkTtlMs ?? DEFAULT_LINK_TTL_MS,
363
+ maxEntries: options.maxTrackedTransactions ?? DEFAULT_MAX_TRACKED
364
+ });
365
+ const formatAddress = safely("configure address mode", () => resolveAddressFormatter(options.address), OFF_ADDRESS_FORMATTER);
366
+ const errorMessages = safely("configure error message mode", () => resolveErrorMessageMode(options.errorMessages), "off");
367
+ let tracer;
368
+ const getTracer = () => {
369
+ tracer ??= (options.tracerProvider ?? trace.getTracerProvider()).getTracer(INSTRUMENTATION_NAME, VERSION);
370
+ return tracer;
371
+ };
372
+ const nonSensitive = (attributes) => Object.fromEntries(Object.entries(attributes).filter(([key]) => NON_SENSITIVE_KEYS.has(key)));
373
+ const redact = (attributes) => {
374
+ if (!options.redact) return attributes;
375
+ let redacted;
376
+ try {
377
+ redacted = options.redact({ ...attributes });
378
+ } catch (error) {
379
+ diag.error("hashspan: redaction hook failed; recording non-sensitive attributes only", error);
380
+ return nonSensitive(attributes);
381
+ }
382
+ if (typeof redacted !== "object" || redacted === null || Array.isArray(redacted)) {
383
+ diag.error("hashspan: redaction hook must return an attributes object; recording non-sensitive attributes only");
384
+ return nonSensitive(attributes);
385
+ }
386
+ return redacted;
387
+ };
388
+ /**
389
+ * Exception event attributes for `error`, per the error message mode. The error object itself is never handed
390
+ * to the SDK: its message and stack can carry addresses and calldata (docs/adr/0006-error-privacy.md).
391
+ */
392
+ const exceptionAttributes = (type, error) => {
393
+ const attributes = { [ATTR_EXCEPTION_TYPE]: type };
394
+ if (errorMessages === "off") return attributes;
395
+ const message = error instanceof Error ? error.message : String(error);
396
+ if (errorMessages === "sanitized") {
397
+ const sanitized = sanitizeErrorMessage(message, formatAddress);
398
+ if (sanitized) attributes[ATTR_EXCEPTION_MESSAGE] = sanitized;
399
+ return attributes;
400
+ }
401
+ attributes[ATTR_EXCEPTION_MESSAGE] = message;
402
+ if (error instanceof Error && error.stack) attributes[ATTR_EXCEPTION_STACKTRACE] = error.stack;
403
+ return attributes;
404
+ };
405
+ /** Error names are free text too: they follow the address mode and pass through the redaction hook. */
406
+ const markError = (span, errorName, error) => {
407
+ const type = formatAddressesIn(errorName, formatAddress);
408
+ let message;
409
+ if (error !== void 0) {
410
+ const exception = redact(exceptionAttributes(type, error));
411
+ span.addEvent(EXCEPTION_EVENT, exception);
412
+ const recorded = exception[ATTR_EXCEPTION_MESSAGE];
413
+ if (typeof recorded === "string") message = recorded;
414
+ }
415
+ span.setAttributes(redact({ [ATTR_ERROR_TYPE]: type }));
416
+ span.setStatus({
417
+ code: SpanStatusCode.ERROR,
418
+ ...message !== void 0 ? { message } : {}
419
+ });
420
+ };
421
+ /** Ends a span exactly once; the span is always ended even if recording attributes fails. */
422
+ const finisher = (span) => {
423
+ let ended = false;
424
+ return (what, record, endTime) => {
425
+ if (ended) return;
426
+ ended = true;
427
+ try {
428
+ record();
429
+ } catch (error) {
430
+ diag.error(`hashspan: failed to ${what}`, error);
431
+ } finally {
432
+ safely("end span", () => span.end(endTime), void 0);
433
+ }
434
+ };
435
+ };
436
+ const setAddress = (attributes, key, address) => {
437
+ if (address === void 0) return;
438
+ const formatted = formatAddress(address);
439
+ if (formatted !== void 0) attributes[key] = formatted;
440
+ };
441
+ const baseAttributes = (chainId, operation, ctx) => ({
442
+ [ATTR_BLOCKCHAIN_SYSTEM]: "evm",
443
+ [ATTR_BLOCKCHAIN_CHAIN_ID]: chainId,
444
+ [ATTR_BLOCKCHAIN_OPERATION_NAME]: operation,
445
+ ...agentAttributes(ctx, options.agent)
446
+ });
447
+ const startSend = (input, parentCtx) => {
448
+ const parent = parentCtx ?? context.active();
449
+ const attributes = baseAttributes(input.chainId, BLOCKCHAIN_OPERATION_NAME_VALUE_SEND, parent);
450
+ setAddress(attributes, ATTR_BLOCKCHAIN_TX_FROM, input.from);
451
+ setAddress(attributes, ATTR_BLOCKCHAIN_TX_TO, input.to);
452
+ if (input.value !== void 0) attributes[ATTR_BLOCKCHAIN_TX_VALUE] = input.value.toString();
453
+ if (input.nonce !== void 0) attributes[ATTR_BLOCKCHAIN_TX_NONCE] = input.nonce;
454
+ if (input.functionName !== void 0) attributes[ATTR_BLOCKCHAIN_CONTRACT_FUNCTION_NAME] = input.functionName;
455
+ if (input.functionSelector !== void 0) attributes[ATTR_BLOCKCHAIN_CONTRACT_FUNCTION_SELECTOR] = input.functionSelector;
456
+ if (options.recordFunctionArguments === true && input.functionArguments !== void 0) try {
457
+ attributes[ATTR_BLOCKCHAIN_CONTRACT_FUNCTION_ARGUMENTS] = serializeFunctionArguments(input.functionArguments, formatAddress);
458
+ } catch (error) {
459
+ diag.debug(`hashspan: could not serialize function arguments (${errorType(error)})`);
460
+ }
461
+ const span = getTracer().startSpan(`send ${input.chainId}`, {
462
+ kind: SpanKind.CLIENT,
463
+ attributes: redact(attributes),
464
+ ...input.startTime !== void 0 ? { startTime: input.startTime } : {}
465
+ }, parent);
466
+ const finish = finisher(span);
467
+ return {
468
+ end: (hash, endTime) => finish("record transaction hash", () => {
469
+ links.set(input.chainId, hash, {
470
+ spanContext: span.spanContext(),
471
+ parent
472
+ });
473
+ span.setAttributes(redact({ [ATTR_BLOCKCHAIN_TX_HASH]: hash }));
474
+ }, endTime),
475
+ fail: (error, endTime) => finish("record send failure", () => markError(span, errorType(error), error), endTime)
476
+ };
477
+ };
478
+ const receiptAttributes = (receipt) => {
479
+ const attributes = {
480
+ [ATTR_BLOCKCHAIN_TX_STATUS]: receipt.status === "reverted" ? BLOCKCHAIN_TX_STATUS_VALUE_REVERTED : BLOCKCHAIN_TX_STATUS_VALUE_SUCCESS,
481
+ [ATTR_BLOCKCHAIN_BLOCK_NUMBER]: toInt(receipt.blockNumber),
482
+ [ATTR_BLOCKCHAIN_TX_GAS_USED]: toInt(receipt.gasUsed)
483
+ };
484
+ const l1Fee = receipt.l1Fee ?? void 0;
485
+ if (l1Fee !== void 0) attributes[ATTR_BLOCKCHAIN_TX_L1_FEE] = l1Fee.toString();
486
+ if (receipt.effectiveGasPrice !== void 0) {
487
+ attributes[ATTR_BLOCKCHAIN_TX_EFFECTIVE_GAS_PRICE] = receipt.effectiveGasPrice.toString();
488
+ const executionFee = BigInt(receipt.gasUsed) * receipt.effectiveGasPrice;
489
+ attributes[ATTR_BLOCKCHAIN_TX_FEE] = (executionFee + (l1Fee ?? 0n)).toString();
490
+ }
491
+ if (receipt.revertReason !== void 0) attributes[ATTR_BLOCKCHAIN_TX_REVERT_REASON] = formatAddressesIn(receipt.revertReason, formatAddress);
492
+ return attributes;
493
+ };
494
+ /** Opens the confirm span of a transaction; `replacing` is the confirm span of the transaction it replaced. */
495
+ const openConfirm = (input, parentCtx, replacing) => {
496
+ const sent = links.get(input.chainId, input.hash);
497
+ const active = context.active();
498
+ const parent = replacing?.parent ?? parentCtx ?? (trace.getSpan(active) ? active : sent?.parent ?? active);
499
+ const attributes = baseAttributes(input.chainId, BLOCKCHAIN_OPERATION_NAME_VALUE_CONFIRM, parent);
500
+ attributes[ATTR_BLOCKCHAIN_TX_HASH] = input.hash;
501
+ const spanLinks = [...replacing?.links ?? [], ...sent ? [{ context: sent.spanContext }] : []];
502
+ const explicitStart = input.startTime ?? replacing?.startTime;
503
+ const span = getTracer().startSpan(`confirm ${input.chainId}`, {
504
+ kind: SpanKind.CLIENT,
505
+ attributes: redact(attributes),
506
+ links: spanLinks,
507
+ ...explicitStart !== void 0 ? { startTime: explicitStart } : {}
508
+ }, parent);
509
+ const finish = finisher(span);
510
+ return {
511
+ active: 0,
512
+ ended: false,
513
+ origin: {
514
+ parent,
515
+ startTime: explicitStart ?? /* @__PURE__ */ new Date(),
516
+ links: [{ context: span.spanContext() }, ...sent ? [{ context: sent.spanContext }] : []]
517
+ },
518
+ receipt: (receipt, endTime) => finish("record receipt", () => {
519
+ span.setAttributes(redact(receiptAttributes(receipt)));
520
+ if (receipt.status === "reverted") markError(span, BLOCKCHAIN_TX_STATUS_VALUE_REVERTED);
521
+ }, endTime),
522
+ timeout: (endTime) => finish("record confirmation timeout", () => {
523
+ span.setAttributes(redact({ [ATTR_BLOCKCHAIN_TX_STATUS]: BLOCKCHAIN_TX_STATUS_VALUE_TIMEOUT }));
524
+ markError(span, BLOCKCHAIN_TX_STATUS_VALUE_TIMEOUT);
525
+ }, endTime),
526
+ fail: (error, endTime) => finish("record confirmation failure", () => markError(span, errorType(error), error), endTime),
527
+ replaced: (hash, reason, endTime) => finish("record replacement", () => {
528
+ const attributes = {
529
+ [ATTR_BLOCKCHAIN_TX_STATUS]: BLOCKCHAIN_TX_STATUS_VALUE_REPLACED,
530
+ [ATTR_BLOCKCHAIN_TX_REPLACEMENT_HASH]: hash
531
+ };
532
+ if (reason !== void 0 && REPLACEMENT_REASONS.has(reason)) attributes[ATTR_BLOCKCHAIN_TX_REPLACEMENT_REASON] = reason;
533
+ span.setAttributes(redact(attributes));
534
+ }, endTime),
535
+ unattributable: (endTime) => finish("record unattributable receipt", () => markError(span, ERROR_TYPE_VALUE_OTHER), endTime)
536
+ };
537
+ };
538
+ /**
539
+ * Records `receipt` for the replacing transaction `hash`: ends its in-flight confirm span, does nothing if it
540
+ * already settled, and otherwise opens one that inherits parent, start time and links from `original`.
541
+ */
542
+ const recordReplacing = (chainId, hash, receipt, original, endTime) => {
543
+ const current = confirmations.get(chainId, hash);
544
+ if (current === "settled" || current?.ended) return;
545
+ const confirm = current ?? openConfirm({
546
+ chainId,
547
+ hash
548
+ }, void 0, original);
549
+ if (!current) confirmations.start(chainId, hash, confirm);
550
+ confirm.ended = true;
551
+ confirmations.settle(chainId, hash, confirm);
552
+ const { replacementReason: _reason, ...mined } = receipt;
553
+ confirm.receipt(mined, endTime);
554
+ };
555
+ /** Ends `shared` with `receipt`, attributing it to the transaction that was mined (docs/adr/0008). */
556
+ const endWithReceipt = (chainId, hash, shared, receipt, endTime) => {
557
+ const mined = receipt.transactionHash;
558
+ if (mined === void 0) {
559
+ confirmations.settle(chainId, hash, shared);
560
+ shared.receipt(receipt, endTime);
561
+ return;
562
+ }
563
+ if (typeof mined !== "string" || !TX_HASH.test(mined)) {
564
+ diag.warn("hashspan: receipt has an invalid transaction hash; not recording it");
565
+ confirmations.release(chainId, hash, shared);
566
+ shared.unattributable(endTime);
567
+ return;
568
+ }
569
+ if (mined.toLowerCase() === hash.toLowerCase()) {
570
+ confirmations.settle(chainId, hash, shared);
571
+ shared.receipt(receipt, endTime);
572
+ return;
573
+ }
574
+ confirmations.settle(chainId, hash, shared);
575
+ shared.replaced(mined, receipt.replacementReason, endTime);
576
+ safely("record replacing transaction", () => recordReplacing(chainId, mined, receipt, shared.origin, endTime), void 0);
577
+ };
578
+ /**
579
+ * Joins the confirm span of the transaction, opening it for the first handle. A receipt from any handle ends the
580
+ * span; a timeout or failure only ends it when it is the last handle still waiting.
581
+ */
582
+ const startConfirm = (input, parentCtx) => {
583
+ const { chainId, hash } = input;
584
+ const current = confirmations.get(chainId, hash);
585
+ if (current === "settled") return NOOP_CONFIRM;
586
+ let confirm = current;
587
+ if (!confirm) {
588
+ confirm = openConfirm(input, parentCtx);
589
+ confirmations.start(chainId, hash, confirm);
590
+ }
591
+ const shared = confirm;
592
+ shared.active += 1;
593
+ let done = false;
594
+ const withdraw = (end) => {
595
+ if (done || shared.ended) return;
596
+ done = true;
597
+ shared.active -= 1;
598
+ if (shared.active > 0) return;
599
+ shared.ended = true;
600
+ confirmations.release(chainId, hash, shared);
601
+ end();
602
+ };
603
+ return {
604
+ end: (receipt, endTime) => {
605
+ if (done || shared.ended) return;
606
+ done = true;
607
+ shared.active -= 1;
608
+ shared.ended = true;
609
+ safely("record receipt", () => endWithReceipt(chainId, hash, shared, receipt, endTime), void 0);
610
+ },
611
+ timeout: (endTime) => withdraw(() => shared.timeout(endTime)),
612
+ fail: (error, endTime) => withdraw(() => shared.fail(error, endTime))
613
+ };
614
+ };
615
+ return {
616
+ startSend: (input, parent) => safely("start send span", () => startSend(input, parent), NOOP_SEND),
617
+ startConfirm: (input, parent) => safely("start confirm span", () => startConfirm(input, parent), NOOP_CONFIRM)
618
+ };
619
+ }
620
+ //#endregion
621
+ export { ATTR_BLOCKCHAIN_BLOCK_NUMBER, ATTR_BLOCKCHAIN_CHAIN_ID, ATTR_BLOCKCHAIN_CONTRACT_FUNCTION_ARGUMENTS, ATTR_BLOCKCHAIN_CONTRACT_FUNCTION_NAME, ATTR_BLOCKCHAIN_CONTRACT_FUNCTION_SELECTOR, ATTR_BLOCKCHAIN_OPERATION_NAME, ATTR_BLOCKCHAIN_SYSTEM, ATTR_BLOCKCHAIN_TX_EFFECTIVE_GAS_PRICE, ATTR_BLOCKCHAIN_TX_FEE, ATTR_BLOCKCHAIN_TX_FROM, ATTR_BLOCKCHAIN_TX_GAS_USED, ATTR_BLOCKCHAIN_TX_HASH, ATTR_BLOCKCHAIN_TX_L1_FEE, ATTR_BLOCKCHAIN_TX_NONCE, ATTR_BLOCKCHAIN_TX_REPLACEMENT_HASH, ATTR_BLOCKCHAIN_TX_REPLACEMENT_REASON, ATTR_BLOCKCHAIN_TX_REVERT_REASON, ATTR_BLOCKCHAIN_TX_STATUS, ATTR_BLOCKCHAIN_TX_TO, ATTR_BLOCKCHAIN_TX_VALUE, ATTR_ERROR_TYPE, ATTR_GEN_AI_AGENT_ID, ATTR_GEN_AI_AGENT_NAME, BLOCKCHAIN_OPERATION_NAME_VALUE_CONFIRM, BLOCKCHAIN_OPERATION_NAME_VALUE_SEND, BLOCKCHAIN_SYSTEM_VALUE_EVM, BLOCKCHAIN_TX_REPLACEMENT_REASON_VALUE_CANCELLED, BLOCKCHAIN_TX_REPLACEMENT_REASON_VALUE_REPLACED, BLOCKCHAIN_TX_REPLACEMENT_REASON_VALUE_REPRICED, BLOCKCHAIN_TX_STATUS_VALUE_REPLACED, BLOCKCHAIN_TX_STATUS_VALUE_REVERTED, BLOCKCHAIN_TX_STATUS_VALUE_SUCCESS, BLOCKCHAIN_TX_STATUS_VALUE_TIMEOUT, ERROR_TYPE_VALUE_OTHER, VERSION, createTxTracker };
package/package.json CHANGED
@@ -1,7 +1,66 @@
1
1
  {
2
2
  "name": "@hashspan/core",
3
- "version": "0.0.0",
4
- "description": "Placeholder; see https://github.com/selimaytac/hashspan",
3
+ "version": "0.1.0",
4
+ "description": "Transaction lifecycle tracing for on-chain actions of AI agents, built on OpenTelemetry.",
5
5
  "license": "Apache-2.0",
6
- "repository": { "type": "git", "url": "git+https://github.com/selimaytac/hashspan.git" }
7
- }
6
+ "author": "Selim Aytac",
7
+ "homepage": "https://github.com/selimaytac/hashspan/tree/main/packages/core#readme",
8
+ "bugs": {
9
+ "url": "https://github.com/selimaytac/hashspan/issues"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/selimaytac/hashspan.git",
14
+ "directory": "packages/core"
15
+ },
16
+ "keywords": [
17
+ "opentelemetry",
18
+ "tracing",
19
+ "blockchain",
20
+ "evm",
21
+ "ai-agents",
22
+ "observability"
23
+ ],
24
+ "type": "module",
25
+ "sideEffects": false,
26
+ "main": "./dist/index.cjs",
27
+ "module": "./dist/index.mjs",
28
+ "types": "./dist/index.d.cts",
29
+ "exports": {
30
+ ".": {
31
+ "import": {
32
+ "types": "./dist/index.d.mts",
33
+ "default": "./dist/index.mjs"
34
+ },
35
+ "require": {
36
+ "types": "./dist/index.d.cts",
37
+ "default": "./dist/index.cjs"
38
+ }
39
+ },
40
+ "./package.json": "./package.json"
41
+ },
42
+ "files": [
43
+ "dist",
44
+ "LICENSE",
45
+ "README.md"
46
+ ],
47
+ "peerDependencies": {
48
+ "@opentelemetry/api": "^1.9.0"
49
+ },
50
+ "publishConfig": {
51
+ "access": "public",
52
+ "provenance": true
53
+ },
54
+ "devDependencies": {
55
+ "@opentelemetry/api": "^1.9.1",
56
+ "@opentelemetry/sdk-trace-base": "^2.11.0",
57
+ "@opentelemetry/sdk-trace-node": "^2.11.0"
58
+ },
59
+ "engines": {
60
+ "node": ">=18"
61
+ },
62
+ "scripts": {
63
+ "build": "tsdown",
64
+ "typecheck": "tsc -p tsconfig.json && tsc -p tsconfig.test.json"
65
+ }
66
+ }