@hashspan/core 0.3.0 → 0.5.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
@@ -8,8 +8,11 @@ Transaction lifecycle tracing for the on-chain actions of AI agents, built on Op
8
8
  - **`confirm {chainId}`**: waiting for the receipt: status, block, gas, fees (including the OP-stack L1 fee) and
9
9
  revert reason. It carries a span link to its `send` span.
10
10
 
11
+ Payments that another party settles on chain, such as x402 payments, become a **`payment {chainId}`** span instead
12
+ of a `send` span.
13
+
11
14
  The core is library-agnostic and read-only: it never signs, sends or fetches anything. Adapters such as
12
- [`@hashspan/viem`](https://github.com/selimaytac/hashspan/tree/@hashspan/core@0.3.0/packages/viem) call it for you. Use the core directly to instrument any other send path.
15
+ [`@hashspan/viem`](https://github.com/selimaytac/hashspan/tree/@hashspan/core@0.5.0/packages/viem) call it for you. Use the core directly to instrument any other send path.
13
16
 
14
17
  ## Install
15
18
 
@@ -17,7 +20,7 @@ The core is library-agnostic and read-only: it never signs, sends or fetches any
17
20
  npm install @hashspan/core @opentelemetry/api
18
21
  ```
19
22
 
20
- `@opentelemetry/api` is the only peer dependency. Bring your own OpenTelemetry SDK and exporter. Requires Node.js 22.3
23
+ `@opentelemetry/api` is the only peer dependency. Bring your own [OpenTelemetry SDK and exporter](https://opentelemetry.io/docs/languages/js/getting-started/nodejs/). Requires Node.js 22.3
21
24
  or later; in other runtimes, `hashed` address mode needs a custom `hash` function and otherwise records no addresses,
22
25
  with a `diag` warning.
23
26
 
@@ -25,15 +28,17 @@ with a `diag` warning.
25
28
 
26
29
  ```ts
27
30
  import { createTxTracker } from '@hashspan/core';
31
+ import { context } from '@opentelemetry/api';
28
32
 
29
33
  const tracker = createTxTracker({ agent: { name: 'treasury-bot' } });
30
34
 
31
35
  // Inside your tool, where the agent framework's span is active:
32
36
  const send = tracker.startSend({ chainId: 8453, from, to, value, functionName: 'transfer' });
33
- let hash;
37
+ let hash: string;
34
38
  try {
35
- hash = await sendSomehow();
36
- send.end(hash);
39
+ // Run in the send span's context, so that wallet or RPC spans of the call nest under it.
40
+ hash = await context.with(send.context, () => sendSomehow());
41
+ send.end({ hash });
37
42
  } catch (error) {
38
43
  send.fail(error);
39
44
  throw error;
@@ -57,14 +62,21 @@ confirm.end({
57
62
  share one confirm span. A receipt from any of them ends it; a timeout or failure ends it once every caller gave up.
58
63
  End every handle you start, since an open handle keeps the shared span open.
59
64
 
60
- `send.fail(error, endTime, { errorType })` records a library's machine-readable error code as `error.type` instead
65
+ `send.fail(error, { errorType })` records a library's machine-readable error code as `error.type` instead
61
66
  of the error's class name, if it is a short identifier (`[A-Za-z0-9_.-]`, at most 64 characters); `exception.type`
62
67
  stays the class name.
63
68
 
64
- Both calls accept an explicit parent `Context` as a second argument. An integration that learns about a call only
65
- after it started can record it after the fact: pass `startTime` in the input and the end time as the last argument
66
- of the handle method, e.g. `send.end(hash, endTime)` ([ADR 0009](https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.3.0/docs/adr/0009-telemetry-off-the-call-path.md)). Every method is safe to call: failures inside
67
- the instrumentation are reported through `diag` and never thrown into your code.
69
+ `tracker.startPayment({ chainId, protocol, payer, recipient, asset, amount })` records a payment that another
70
+ party settles on chain, such as an x402 facilitator, as a `payment {chainId}` span; end it with
71
+ `end({ status, hash })` or `fail(error)`. A settlement with a hash links the transaction's confirm span to the payment
72
+ span ([ADR 0013](https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0013-x402-payments.md)).
73
+
74
+ All three calls accept an explicit parent `Context` as a second argument. An integration that learns about a call only
75
+ after it started can record it after the fact: pass `startTime` in the input and `endTime` in the options of the
76
+ handle method, e.g. `send.end({ hash }, { endTime })` ([ADR 0009](https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0009-telemetry-off-the-call-path.md)). Every method is safe to call: failures inside
77
+ the instrumentation are reported through `diag` and never thrown into your code. The positional forms of earlier
78
+ releases, `send.end(hash, endTime)` and `send.fail(error, endTime, { errorType })`, still work and are deprecated
79
+ until 1.0 ([ADR 0014](https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0014-core-api-boundary.md)).
68
80
 
69
81
  ## Options
70
82
 
@@ -72,7 +84,8 @@ the instrumentation are reported through `diag` and never thrown into your code.
72
84
  |---|---|---|
73
85
  | `tracerProvider` | global provider | Tracer provider to use |
74
86
  | `address` | `'raw'` | `'raw'`, `'hashed'`, `'off'`, or `{ mode: 'hashed', hash: (address) => string }` |
75
- | `errorMessages` | `'off'` | What failed spans record about the error: `'off'` (type only), `'sanitized'` (first line, addresses per `address` mode, calldata removed; in `hashed` and `off` mode any hex longer than an address) or `'raw'` (full message and stack trace). `'raw'` can record RPC URLs that include API keys, as some libraries put the request URL in the message; `'sanitized'` keeps only the first line (viem puts the URL on a later line), which is best effort. See [ADR 0006](https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.3.0/docs/adr/0006-error-privacy.md) |
87
+ | `errorMessages` | `'off'` | What failed spans record about the error: `'off'` (type only), `'sanitized'` (first line, addresses per `address` mode, calldata removed; in `hashed` and `off` mode any hex longer than an address) or `'raw'` (full message and stack trace). `'raw'` can record RPC URLs that include API keys, as some libraries put the request URL in the message; `'sanitized'` keeps only the first line (viem puts the URL on a later line), which is best effort. See [ADR 0006](https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0006-error-privacy.md) |
88
+ | `paymentResource` | `'origin'` | How much of a paid resource's URL `x402.resource` records: `'origin'` (scheme, host and port; nothing for a resource that is not a URL), `'path'` (also the path, never the query string, fragment or user info) or `'off'`. Paths often carry user or account identifiers. At most 512 characters are recorded |
76
89
  | `recordFunctionArguments` | `false` | Record `functionArguments` as a JSON array in `blockchain.contract.function.arguments`: bigints as decimal strings, addresses per `address` mode (longer hex values become `<hex>` in `hashed` and `off` mode), at most 4096 characters. Reads only own enumerable data properties: `toJSON()` and getters are never called, so a `Date` records as `{}`; a Proxy's traps still run |
77
90
  | `agent` | none | Agent `{ id, name }`; a field set here always wins, unset fields come from the Baggage entries `gen_ai.agent.id` / `gen_ai.agent.name` |
78
91
  | `agentFromBaggage` | `true` | Read agent identity fields that `agent` leaves unset from Baggage; set to `false` in services that accept requests from outside their trust boundary |
@@ -86,7 +99,7 @@ Chain id, transaction hash, sender/recipient (per `address` mode), value, nonce,
86
99
  on confirmation, status, block number, gas used, effective gas price, L1 fee, total fee and revert reason. Decoded
87
100
  call arguments are recorded only with `recordFunctionArguments`, and error messages only with `errorMessages`.
88
101
  Attribute definitions:
89
- [docs/semconv.md](https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.3.0/docs/semconv.md).
102
+ [docs/semconv.md](https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/semconv.md).
90
103
 
91
104
  ## Privacy notes
92
105
 
@@ -101,9 +114,13 @@ Attribute definitions:
101
114
  propagated, or strip the entries before outbound calls.
102
115
  - **Inbound Baggage can claim an identity.** A caller can send Baggage entries with any agent id. A field set in the
103
116
  `agent` option cannot be overridden that way; to ignore identity from Baggage entirely, set `agentFromBaggage: false`
104
- ([ADR 0011](https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.3.0/docs/adr/0011-agent-identity-precedence.md)).
117
+ ([ADR 0011](https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0011-agent-identity-precedence.md)).
105
118
  - The redaction hook (`redact`) runs last on every attribute set and on exception attributes; use it for anything
106
119
  else your policy forbids.
120
+ - **Your callbacks' errors go to the diagnostic logger.** If a custom `hash` function or the `redact` hook throws,
121
+ its error object is logged through the OpenTelemetry `diag` logger, outside the address mode and the redaction
122
+ hook. Errors of the instrumented call never are. Do not put sensitive values, such as the address being hashed,
123
+ into errors your callbacks throw, or route `diag` to a sink your policy allows.
107
124
 
108
125
  ## License
109
126
 
package/dist/index.cjs CHANGED
@@ -5,7 +5,8 @@ const ATTR_GEN_AI_AGENT_ID = "gen_ai.agent.id";
5
5
  const ATTR_GEN_AI_AGENT_NAME = "gen_ai.agent.name";
6
6
  /**
7
7
  * Agent identity as GenAI attributes. A field set in the static identity always wins; Baggage, which a remote caller
8
- * can set, only fills fields it leaves unset, and is not read at all with `fromBaggage` false (docs/adr/0011).
8
+ * can set, only fills fields it leaves unset, and is not read at all with `fromBaggage` false
9
+ * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0011-agent-identity-precedence.md).
9
10
  */
10
11
  function agentAttributes(ctx, identity, fromBaggage = true) {
11
12
  const baggage = fromBaggage ? _opentelemetry_api.propagation.getBaggage(ctx) : void 0;
@@ -21,8 +22,8 @@ function agentAttributes(ctx, identity, fromBaggage = true) {
21
22
  /**
22
23
  * Attribute keys emitted by hashspan.
23
24
  *
24
- * Stability: development. See docs/semconv.md for definitions and value types.
25
- * These names are a public contract: changes follow the deprecation policy in AGENTS.md.
25
+ * Stability: development. See https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/semconv.md for
26
+ * definitions and value types. These names are a public contract: changes follow the deprecation policy in AGENTS.md.
26
27
  */
27
28
  const ATTR_BLOCKCHAIN_SYSTEM = "blockchain.system";
28
29
  const ATTR_BLOCKCHAIN_CHAIN_ID = "blockchain.chain.id";
@@ -43,16 +44,46 @@ const ATTR_BLOCKCHAIN_TX_REPLACEMENT_REASON = "blockchain.tx.replacement.reason"
43
44
  const ATTR_BLOCKCHAIN_BLOCK_NUMBER = "blockchain.block.number";
44
45
  const ATTR_BLOCKCHAIN_CONTRACT_FUNCTION_NAME = "blockchain.contract.function.name";
45
46
  const ATTR_BLOCKCHAIN_CONTRACT_FUNCTION_SELECTOR = "blockchain.contract.function.selector";
46
- /** Opt-in: decoded call arguments as a JSON array. See docs/adr/0004-privacy-defaults.md. */
47
+ /**
48
+ * Opt-in: decoded call arguments as a JSON array. See
49
+ * https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0004-privacy-defaults.md.
50
+ */
47
51
  const ATTR_BLOCKCHAIN_CONTRACT_FUNCTION_ARGUMENTS = "blockchain.contract.function.arguments";
52
+ /**
53
+ * Payments settled on chain by a party other than the agent, e.g. an x402 facilitator. See
54
+ * https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0013-x402-payments.md.
55
+ */
56
+ const ATTR_BLOCKCHAIN_PAYMENT_PROTOCOL = "blockchain.payment.protocol";
57
+ const ATTR_BLOCKCHAIN_PAYMENT_PAYER = "blockchain.payment.payer";
58
+ const ATTR_BLOCKCHAIN_PAYMENT_RECIPIENT = "blockchain.payment.recipient";
59
+ const ATTR_BLOCKCHAIN_PAYMENT_ASSET = "blockchain.payment.asset";
60
+ const ATTR_BLOCKCHAIN_PAYMENT_AMOUNT = "blockchain.payment.amount";
61
+ const ATTR_BLOCKCHAIN_PAYMENT_STATUS = "blockchain.payment.status";
62
+ /** The amount the settling party reports it settled, e.g. less than the authorized maximum with x402 `upto`. */
63
+ const ATTR_BLOCKCHAIN_PAYMENT_SETTLED_AMOUNT = "blockchain.payment.settled_amount";
64
+ /** x402's own payment fields. */
65
+ const ATTR_X402_SCHEME = "x402.scheme";
66
+ const ATTR_X402_RESOURCE = "x402.resource";
48
67
  /** Values for {@link ATTR_BLOCKCHAIN_SYSTEM}. */
49
68
  const BLOCKCHAIN_SYSTEM_VALUE_EVM = "evm";
50
69
  /** Values for {@link ATTR_BLOCKCHAIN_OPERATION_NAME}. */
51
70
  const BLOCKCHAIN_OPERATION_NAME_VALUE_SEND = "send";
52
71
  const BLOCKCHAIN_OPERATION_NAME_VALUE_CONFIRM = "confirm";
72
+ const BLOCKCHAIN_OPERATION_NAME_VALUE_PAYMENT = "payment";
73
+ /** Values for {@link ATTR_BLOCKCHAIN_PAYMENT_PROTOCOL}. */
74
+ const BLOCKCHAIN_PAYMENT_PROTOCOL_VALUE_X402 = "x402";
75
+ /** Values for {@link ATTR_BLOCKCHAIN_PAYMENT_STATUS}. */
76
+ const BLOCKCHAIN_PAYMENT_STATUS_VALUE_SETTLED = "settled";
77
+ const BLOCKCHAIN_PAYMENT_STATUS_VALUE_PENDING = "pending";
78
+ const BLOCKCHAIN_PAYMENT_STATUS_VALUE_FAILED = "failed";
53
79
  /** Values for {@link ATTR_BLOCKCHAIN_TX_STATUS}. */
54
80
  const BLOCKCHAIN_TX_STATUS_VALUE_SUCCESS = "success";
55
81
  const BLOCKCHAIN_TX_STATUS_VALUE_REVERTED = "reverted";
82
+ /**
83
+ * @deprecated No longer recorded: a confirm span that gave up waiting records `error.type` `timeout` and no
84
+ * `blockchain.tx.status`. The constant is removed in 1.0. See
85
+ * https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0016-timeout-is-an-observer-outcome.md.
86
+ */
56
87
  const BLOCKCHAIN_TX_STATUS_VALUE_TIMEOUT = "timeout";
57
88
  const BLOCKCHAIN_TX_STATUS_VALUE_REPLACED = "replaced";
58
89
  /** Values for {@link ATTR_BLOCKCHAIN_TX_REPLACEMENT_REASON}, as reported by the instrumented library. */
@@ -300,9 +331,56 @@ function resolveErrorMessageMode(mode) {
300
331
  _opentelemetry_api.diag.warn(`hashspan: unknown error message mode "${String(mode)}"; recording error types only`);
301
332
  return "off";
302
333
  }
334
+ function resolvePaymentResourceMode(mode) {
335
+ if (mode === void 0 || mode === "origin" || mode === "path" || mode === "off") return mode ?? "origin";
336
+ _opentelemetry_api.diag.warn(`hashspan: unknown payment resource mode "${String(mode)}"; not recording payment resources`);
337
+ return "off";
338
+ }
339
+ /** The origin of a URL: `scheme://`, then the host and port, without user info. */
340
+ const URL_ORIGIN = /^([A-Za-z][A-Za-z0-9+.-]*:\/\/)(?:[^/?#]*@)?([^/?#]+)/;
341
+ /**
342
+ * The part of a payment's resource that `mode` records, or undefined for none. Works on the text, so it never throws
343
+ * for a resource that is not a URL.
344
+ */
345
+ function paymentResourceOf(resource, mode) {
346
+ if (mode === "off" || hidesUserInfo(resource)) return void 0;
347
+ const recorded = mode === "path" ? sanitizeResource(resource) : originOf(resource);
348
+ if (recorded === void 0) return void 0;
349
+ return recorded.length > MAX_RESOURCE_LENGTH ? `${recorded.slice(0, MAX_RESOURCE_LENGTH)}...` : recorded;
350
+ }
351
+ /** Longest `x402.resource` recorded; the value comes from the server that asks for the payment. */
352
+ const MAX_RESOURCE_LENGTH = 512;
353
+ function originOf(resource) {
354
+ const origin = URL_ORIGIN.exec(resource);
355
+ return origin ? `${origin[1]}${origin[2]}` : void 0;
356
+ }
357
+ /**
358
+ * True for `scheme://` text whose user info contains `?` or `#`, such as `https://user:p?ss@host`. A valid URL
359
+ * percent-encodes them; reading such text by its first `?` or `#` would record part of the user info as the host.
360
+ */
361
+ function hidesUserInfo(resource) {
362
+ const scheme = URL_SCHEME.exec(resource);
363
+ if (!scheme) return false;
364
+ const rest = resource.slice(scheme[0].length);
365
+ const slash = rest.indexOf("/");
366
+ const authority = slash === -1 ? rest : rest.slice(0, slash);
367
+ const at = authority.lastIndexOf("@");
368
+ return at !== -1 && /[?#]/.test(authority.slice(0, at));
369
+ }
370
+ const URL_SCHEME = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//;
371
+ /** `scheme://user:password@` at the start of a URL; the user info is removed. */
372
+ const URL_USER_INFO = /^([A-Za-z][A-Za-z0-9+.-]*:\/\/)[^/?#]*@/;
373
+ /**
374
+ * The resource of a payment without what can carry credentials: the query string, the fragment and the user info.
375
+ * Works on the text, so names that are not URLs are kept as they are.
376
+ */
377
+ function sanitizeResource(resource) {
378
+ const end = resource.search(/[?#]/);
379
+ return (end === -1 ? resource : resource.slice(0, end)).replace(URL_USER_INFO, "$1");
380
+ }
303
381
  //#endregion
304
382
  //#region src/version.ts
305
- const VERSION = "0.3.0";
383
+ const VERSION = "0.5.0";
306
384
  //#endregion
307
385
  //#region src/tracker.ts
308
386
  const INSTRUMENTATION_NAME = "@hashspan/core";
@@ -322,18 +400,37 @@ const NON_SENSITIVE_KEYS = /* @__PURE__ */ new Set([
322
400
  ATTR_BLOCKCHAIN_TX_STATUS,
323
401
  ATTR_BLOCKCHAIN_TX_REPLACEMENT_HASH,
324
402
  ATTR_BLOCKCHAIN_TX_REPLACEMENT_REASON,
403
+ ATTR_BLOCKCHAIN_PAYMENT_PROTOCOL,
404
+ ATTR_BLOCKCHAIN_PAYMENT_STATUS,
325
405
  ATTR_ERROR_TYPE,
326
406
  ATTR_EXCEPTION_TYPE
327
407
  ]);
328
408
  const TX_HASH = /^0x[0-9a-fA-F]{64}$/;
409
+ const ADDRESS = /^0x[0-9a-fA-F]{40}$/;
410
+ /** A non-negative integer that fits in 256 bits. */
411
+ const AMOUNT = /^(0|[1-9][0-9]{0,77})$/;
412
+ /** `error.type` of a wait that gave up: a confirmation or a payment whose outcome was never learned. */
413
+ const OBSERVER_TIMEOUT = "timeout";
414
+ const PAYMENT_STATUSES = /* @__PURE__ */ new Set([
415
+ BLOCKCHAIN_PAYMENT_STATUS_VALUE_SETTLED,
416
+ BLOCKCHAIN_PAYMENT_STATUS_VALUE_PENDING,
417
+ BLOCKCHAIN_PAYMENT_STATUS_VALUE_FAILED
418
+ ]);
329
419
  const REPLACEMENT_REASONS = /* @__PURE__ */ new Set([
330
420
  BLOCKCHAIN_TX_REPLACEMENT_REASON_VALUE_REPRICED,
331
421
  BLOCKCHAIN_TX_REPLACEMENT_REASON_VALUE_CANCELLED,
332
422
  BLOCKCHAIN_TX_REPLACEMENT_REASON_VALUE_REPLACED
333
423
  ]);
334
- const NOOP_SEND = {
424
+ /** Records nothing; its context is the parent, so a call run in it still nests under the caller. */
425
+ const noopSend = (parent) => ({
426
+ context: parent,
335
427
  end: () => {},
336
428
  fail: () => {}
429
+ });
430
+ const NOOP_PAYMENT = {
431
+ end: () => {},
432
+ fail: () => {},
433
+ timeout: () => {}
337
434
  };
338
435
  const NOOP_CONFIRM = {
339
436
  end: () => {},
@@ -358,7 +455,47 @@ function errorType(error) {
358
455
  return error instanceof Error && error.name ? error.name : ERROR_TYPE_VALUE_OTHER;
359
456
  }
360
457
  const ERROR_TYPE_OVERRIDE = /^[A-Za-z0-9_.-]{1,64}$/;
458
+ /** `value` if it is a short identifier, the only kind of free text recorded from a remote party. */
459
+ function identifier(value) {
460
+ return typeof value === "string" && ERROR_TYPE_OVERRIDE.test(value) ? value : void 0;
461
+ }
462
+ /** A decimal amount, or undefined when `value` is not a non-negative integer. */
463
+ function amount(value) {
464
+ const text = typeof value === "bigint" ? value.toString() : value;
465
+ return typeof text === "string" && AMOUNT.test(text) ? text : void 0;
466
+ }
361
467
  /** The `error.type` for a failure: an adapter's override when it is a short identifier, else the class name. */
468
+ /** A finite number, an `HrTime` pair or a `Date`: what the deprecated positional `endTime` argument takes. */
469
+ function isTimeInput(value) {
470
+ if (typeof value === "number") return Number.isFinite(value);
471
+ if (Array.isArray(value)) return value.length === 2 && typeof value[0] === "number" && typeof value[1] === "number";
472
+ return Object.prototype.toString.call(value) === "[object Date]";
473
+ }
474
+ /**
475
+ * Reads the options of a handle method called as `(what, options?)` or, deprecated, as `(what, endTime?, options?)`
476
+ * (ADR 0014). A positional end time wins over `options.endTime`. Never throws: an argument of neither form, or an
477
+ * end time that is not one, is ignored.
478
+ */
479
+ function handleOptions(second, third) {
480
+ try {
481
+ const positional = isTimeInput(second) ? second : void 0;
482
+ const given = positional !== void 0 || second === void 0 ? third : second;
483
+ if (given !== void 0 && (typeof given !== "object" || given === null || isTimeInput(given))) {
484
+ _opentelemetry_api.diag.debug("hashspan: ignoring a handle argument that is neither options nor an end time");
485
+ return positional !== void 0 ? { endTime: positional } : {};
486
+ }
487
+ const options = given;
488
+ const endTime = positional ?? options?.endTime;
489
+ if (endTime !== void 0 && !isTimeInput(endTime)) _opentelemetry_api.diag.debug("hashspan: ignoring an end time that is not a TimeInput");
490
+ return {
491
+ endTime: isTimeInput(endTime) ? endTime : void 0,
492
+ errorType: options?.errorType
493
+ };
494
+ } catch (error) {
495
+ _opentelemetry_api.diag.debug(`hashspan: could not read handle options (${errorType(error)})`);
496
+ return {};
497
+ }
498
+ }
362
499
  function reportedErrorType(error, options) {
363
500
  const override = options?.errorType;
364
501
  if (override === void 0) return errorType(error);
@@ -366,6 +503,12 @@ function reportedErrorType(error, options) {
366
503
  _opentelemetry_api.diag.debug("hashspan: ignoring an error type that is not a short identifier");
367
504
  return errorType(error);
368
505
  }
506
+ /**
507
+ * Creates a tracker that records transactions as `send` and `confirm` spans, and payments as `payment` spans, with
508
+ * `@opentelemetry/api` (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/semconv.md). It makes
509
+ * no network calls; the caller passes hashes and receipts. Its methods and handles never throw: failures are logged
510
+ * via `diag`, and a method that fails returns a handle that records nothing.
511
+ */
369
512
  function createTxTracker(options = {}) {
370
513
  const links = new LinkStore({
371
514
  ttlMs: options.linkTtlMs ?? DEFAULT_LINK_TTL_MS,
@@ -377,6 +520,7 @@ function createTxTracker(options = {}) {
377
520
  });
378
521
  const formatAddress = safely("configure address mode", () => resolveAddressFormatter(options.address), OFF_ADDRESS_FORMATTER);
379
522
  const errorMessages = safely("configure error message mode", () => resolveErrorMessageMode(options.errorMessages), "off");
523
+ const paymentResource = safely("configure payment resource mode", () => resolvePaymentResourceMode(options.paymentResource), "off");
380
524
  let tracer;
381
525
  const getTracer = () => {
382
526
  tracer ??= (options.tracerProvider ?? _opentelemetry_api.trace.getTracerProvider()).getTracer(INSTRUMENTATION_NAME, VERSION);
@@ -399,8 +543,9 @@ function createTxTracker(options = {}) {
399
543
  return redacted;
400
544
  };
401
545
  /**
402
- * Exception event attributes for `error`, per the error message mode. The error object itself is never handed
403
- * to the SDK: its message and stack can carry addresses and calldata (docs/adr/0006-error-privacy.md).
546
+ * Exception event attributes for `error`, per the error message mode. The error object itself is never handed to
547
+ * the SDK: its message and stack can carry addresses and calldata
548
+ * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0006-error-privacy.md).
404
549
  */
405
550
  const exceptionAttributes = (type, error) => {
406
551
  const attributes = { [ATTR_EXCEPTION_TYPE]: type };
@@ -454,6 +599,10 @@ function createTxTracker(options = {}) {
454
599
  const formatted = formatAddress(address);
455
600
  if (formatted !== void 0) attributes[key] = formatted;
456
601
  };
602
+ /** Records `address` only if it is one: payment addresses come from remote parties. */
603
+ const setPaymentAddress = (attributes, key, address) => {
604
+ if (typeof address === "string" && ADDRESS.test(address)) setAddress(attributes, key, address);
605
+ };
457
606
  const baseAttributes = (chainId, operation, ctx) => ({
458
607
  [ATTR_BLOCKCHAIN_SYSTEM]: "evm",
459
608
  [ATTR_BLOCKCHAIN_CHAIN_ID]: chainId,
@@ -481,14 +630,23 @@ function createTxTracker(options = {}) {
481
630
  }, parent);
482
631
  const finish = finisher(span);
483
632
  return {
484
- end: (hash, endTime) => finish("record transaction hash", () => {
633
+ context: _opentelemetry_api.trace.setSpan(parent, span),
634
+ end: (result, second) => finish("record transaction hash", () => {
635
+ const hash = typeof result === "string" ? result : result?.hash;
636
+ if (typeof hash !== "string") {
637
+ _opentelemetry_api.diag.debug("hashspan: ending a send span without a transaction hash");
638
+ return;
639
+ }
485
640
  links.set(input.chainId, hash, {
486
641
  spanContext: span.spanContext(),
487
642
  parent
488
643
  });
489
644
  span.setAttributes(redact({ [ATTR_BLOCKCHAIN_TX_HASH]: hash }));
490
- }, endTime),
491
- fail: (error, endTime, options) => finish("record send failure", () => markError(span, reportedErrorType(error, options), error, errorType(error)), endTime)
645
+ }, handleOptions(second).endTime),
646
+ fail: (error, second, third) => {
647
+ const options = handleOptions(second, third);
648
+ finish("record send failure", () => markError(span, reportedErrorType(error, options), error, errorType(error)), options.endTime);
649
+ }
492
650
  };
493
651
  };
494
652
  const receiptAttributes = (receipt) => {
@@ -535,10 +693,7 @@ function createTxTracker(options = {}) {
535
693
  span.setAttributes(redact(receiptAttributes(receipt)));
536
694
  if (receipt.status === "reverted") markError(span, BLOCKCHAIN_TX_STATUS_VALUE_REVERTED);
537
695
  }, endTime),
538
- timeout: (endTime) => finish("record confirmation timeout", () => {
539
- span.setAttributes(redact({ [ATTR_BLOCKCHAIN_TX_STATUS]: BLOCKCHAIN_TX_STATUS_VALUE_TIMEOUT }));
540
- markError(span, BLOCKCHAIN_TX_STATUS_VALUE_TIMEOUT);
541
- }, endTime),
696
+ timeout: (endTime) => finish("record confirmation timeout", () => markError(span, OBSERVER_TIMEOUT), endTime),
542
697
  fail: (error, endTime) => finish("record confirmation failure", () => markError(span, errorType(error), error), endTime),
543
698
  replaced: (hash, reason, endTime) => finish("record replacement", () => {
544
699
  const attributes = {
@@ -568,7 +723,10 @@ function createTxTracker(options = {}) {
568
723
  const { replacementReason: _reason, ...mined } = receipt;
569
724
  confirm.receipt(mined, endTime);
570
725
  };
571
- /** Ends `shared` with `receipt`, attributing it to the transaction that was mined (docs/adr/0008). */
726
+ /**
727
+ * Ends `shared` with `receipt`, attributing it to the transaction that was mined
728
+ * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0008-replaced-transactions.md).
729
+ */
572
730
  const endWithReceipt = (chainId, hash, shared, receipt, endTime) => {
573
731
  const mined = receipt.transactionHash;
574
732
  if (mined === void 0) {
@@ -617,20 +775,83 @@ function createTxTracker(options = {}) {
617
775
  end();
618
776
  };
619
777
  return {
620
- end: (receipt, endTime) => {
778
+ end: (receipt, second) => {
779
+ const { endTime } = handleOptions(second);
621
780
  if (done || shared.ended) return;
622
781
  done = true;
623
782
  shared.active -= 1;
624
783
  shared.ended = true;
625
784
  safely("record receipt", () => endWithReceipt(chainId, hash, shared, receipt, endTime), void 0);
626
785
  },
627
- timeout: (endTime) => withdraw(() => shared.timeout(endTime)),
628
- fail: (error, endTime) => withdraw(() => shared.fail(error, endTime))
786
+ timeout: (second) => {
787
+ const { endTime } = handleOptions(second);
788
+ withdraw(() => shared.timeout(endTime));
789
+ },
790
+ fail: (error, second) => {
791
+ const { endTime } = handleOptions(second);
792
+ withdraw(() => shared.fail(error, endTime));
793
+ }
794
+ };
795
+ };
796
+ const startPayment = (input, parentCtx) => {
797
+ const parent = parentCtx ?? _opentelemetry_api.context.active();
798
+ const attributes = baseAttributes(input.chainId, BLOCKCHAIN_OPERATION_NAME_VALUE_PAYMENT, parent);
799
+ const protocol = identifier(input.protocol);
800
+ if (protocol !== void 0) attributes[ATTR_BLOCKCHAIN_PAYMENT_PROTOCOL] = protocol;
801
+ setPaymentAddress(attributes, ATTR_BLOCKCHAIN_PAYMENT_PAYER, input.payer);
802
+ const knownPayer = typeof input.payer === "string" && ADDRESS.test(input.payer);
803
+ setPaymentAddress(attributes, ATTR_BLOCKCHAIN_PAYMENT_RECIPIENT, input.recipient);
804
+ setPaymentAddress(attributes, ATTR_BLOCKCHAIN_PAYMENT_ASSET, input.asset);
805
+ const paid = amount(input.amount);
806
+ if (paid !== void 0) attributes[ATTR_BLOCKCHAIN_PAYMENT_AMOUNT] = paid;
807
+ const scheme = identifier(input.x402?.scheme);
808
+ if (scheme !== void 0) attributes[ATTR_X402_SCHEME] = scheme;
809
+ const resource = input.x402?.resource;
810
+ const recorded = typeof resource === "string" ? paymentResourceOf(resource, paymentResource) : void 0;
811
+ if (recorded) attributes[ATTR_X402_RESOURCE] = formatAddressesIn(recorded, formatAddress);
812
+ const span = getTracer().startSpan(`payment ${input.chainId}`, {
813
+ kind: _opentelemetry_api.SpanKind.CLIENT,
814
+ attributes: redact(attributes),
815
+ ...input.startTime !== void 0 ? { startTime: input.startTime } : {}
816
+ }, parent);
817
+ const finish = finisher(span);
818
+ const recordSettlement = (settlement) => {
819
+ const status = settlement.status;
820
+ if (!PAYMENT_STATUSES.has(status)) {
821
+ _opentelemetry_api.diag.debug("hashspan: ignoring a payment settlement with an unknown status");
822
+ return;
823
+ }
824
+ const settled = { [ATTR_BLOCKCHAIN_PAYMENT_STATUS]: status };
825
+ const hash = settlement.hash;
826
+ if (typeof hash === "string" && TX_HASH.test(hash)) {
827
+ if (!links.get(input.chainId, hash)) links.set(input.chainId, hash, {
828
+ spanContext: span.spanContext(),
829
+ parent
830
+ });
831
+ settled[ATTR_BLOCKCHAIN_TX_HASH] = hash;
832
+ }
833
+ if (!knownPayer) setPaymentAddress(settled, ATTR_BLOCKCHAIN_PAYMENT_PAYER, settlement.payer);
834
+ const settledAmount = amount(settlement.amount);
835
+ if (settledAmount !== void 0) {
836
+ settled[ATTR_BLOCKCHAIN_PAYMENT_SETTLED_AMOUNT] = settledAmount;
837
+ if (paid === void 0) settled[ATTR_BLOCKCHAIN_PAYMENT_AMOUNT] = settledAmount;
838
+ }
839
+ span.setAttributes(redact(settled));
840
+ if (status === "failed") markError(span, identifier(settlement.errorReason) ?? "_OTHER");
841
+ };
842
+ return {
843
+ end: (settlement, options) => finish("record payment settlement", () => recordSettlement(settlement), handleOptions(options).endTime),
844
+ fail: (error, options) => {
845
+ const read = handleOptions(options);
846
+ finish("record payment failure", () => markError(span, reportedErrorType(error, read), error, errorType(error)), read.endTime);
847
+ },
848
+ timeout: (options) => finish("record payment timeout", () => markError(span, OBSERVER_TIMEOUT), handleOptions(options).endTime)
629
849
  };
630
850
  };
631
851
  return {
632
- startSend: (input, parent) => safely("start send span", () => startSend(input, parent), NOOP_SEND),
633
- startConfirm: (input, parent) => safely("start confirm span", () => startConfirm(input, parent), NOOP_CONFIRM)
852
+ startSend: (input, parent) => safely("start send span", () => startSend(input, parent), noopSend(parent ?? _opentelemetry_api.context.active())),
853
+ startConfirm: (input, parent) => safely("start confirm span", () => startConfirm(input, parent), NOOP_CONFIRM),
854
+ startPayment: (input, parent) => safely("start payment span", () => startPayment(input, parent), NOOP_PAYMENT)
634
855
  };
635
856
  }
636
857
  //#endregion
@@ -640,6 +861,13 @@ exports.ATTR_BLOCKCHAIN_CONTRACT_FUNCTION_ARGUMENTS = ATTR_BLOCKCHAIN_CONTRACT_F
640
861
  exports.ATTR_BLOCKCHAIN_CONTRACT_FUNCTION_NAME = ATTR_BLOCKCHAIN_CONTRACT_FUNCTION_NAME;
641
862
  exports.ATTR_BLOCKCHAIN_CONTRACT_FUNCTION_SELECTOR = ATTR_BLOCKCHAIN_CONTRACT_FUNCTION_SELECTOR;
642
863
  exports.ATTR_BLOCKCHAIN_OPERATION_NAME = ATTR_BLOCKCHAIN_OPERATION_NAME;
864
+ exports.ATTR_BLOCKCHAIN_PAYMENT_AMOUNT = ATTR_BLOCKCHAIN_PAYMENT_AMOUNT;
865
+ exports.ATTR_BLOCKCHAIN_PAYMENT_ASSET = ATTR_BLOCKCHAIN_PAYMENT_ASSET;
866
+ exports.ATTR_BLOCKCHAIN_PAYMENT_PAYER = ATTR_BLOCKCHAIN_PAYMENT_PAYER;
867
+ exports.ATTR_BLOCKCHAIN_PAYMENT_PROTOCOL = ATTR_BLOCKCHAIN_PAYMENT_PROTOCOL;
868
+ exports.ATTR_BLOCKCHAIN_PAYMENT_RECIPIENT = ATTR_BLOCKCHAIN_PAYMENT_RECIPIENT;
869
+ exports.ATTR_BLOCKCHAIN_PAYMENT_SETTLED_AMOUNT = ATTR_BLOCKCHAIN_PAYMENT_SETTLED_AMOUNT;
870
+ exports.ATTR_BLOCKCHAIN_PAYMENT_STATUS = ATTR_BLOCKCHAIN_PAYMENT_STATUS;
643
871
  exports.ATTR_BLOCKCHAIN_SYSTEM = ATTR_BLOCKCHAIN_SYSTEM;
644
872
  exports.ATTR_BLOCKCHAIN_TX_EFFECTIVE_GAS_PRICE = ATTR_BLOCKCHAIN_TX_EFFECTIVE_GAS_PRICE;
645
873
  exports.ATTR_BLOCKCHAIN_TX_FEE = ATTR_BLOCKCHAIN_TX_FEE;
@@ -657,8 +885,15 @@ exports.ATTR_BLOCKCHAIN_TX_VALUE = ATTR_BLOCKCHAIN_TX_VALUE;
657
885
  exports.ATTR_ERROR_TYPE = ATTR_ERROR_TYPE;
658
886
  exports.ATTR_GEN_AI_AGENT_ID = ATTR_GEN_AI_AGENT_ID;
659
887
  exports.ATTR_GEN_AI_AGENT_NAME = ATTR_GEN_AI_AGENT_NAME;
888
+ exports.ATTR_X402_RESOURCE = ATTR_X402_RESOURCE;
889
+ exports.ATTR_X402_SCHEME = ATTR_X402_SCHEME;
660
890
  exports.BLOCKCHAIN_OPERATION_NAME_VALUE_CONFIRM = BLOCKCHAIN_OPERATION_NAME_VALUE_CONFIRM;
891
+ exports.BLOCKCHAIN_OPERATION_NAME_VALUE_PAYMENT = BLOCKCHAIN_OPERATION_NAME_VALUE_PAYMENT;
661
892
  exports.BLOCKCHAIN_OPERATION_NAME_VALUE_SEND = BLOCKCHAIN_OPERATION_NAME_VALUE_SEND;
893
+ exports.BLOCKCHAIN_PAYMENT_PROTOCOL_VALUE_X402 = BLOCKCHAIN_PAYMENT_PROTOCOL_VALUE_X402;
894
+ exports.BLOCKCHAIN_PAYMENT_STATUS_VALUE_FAILED = BLOCKCHAIN_PAYMENT_STATUS_VALUE_FAILED;
895
+ exports.BLOCKCHAIN_PAYMENT_STATUS_VALUE_PENDING = BLOCKCHAIN_PAYMENT_STATUS_VALUE_PENDING;
896
+ exports.BLOCKCHAIN_PAYMENT_STATUS_VALUE_SETTLED = BLOCKCHAIN_PAYMENT_STATUS_VALUE_SETTLED;
662
897
  exports.BLOCKCHAIN_SYSTEM_VALUE_EVM = BLOCKCHAIN_SYSTEM_VALUE_EVM;
663
898
  exports.BLOCKCHAIN_TX_REPLACEMENT_REASON_VALUE_CANCELLED = BLOCKCHAIN_TX_REPLACEMENT_REASON_VALUE_CANCELLED;
664
899
  exports.BLOCKCHAIN_TX_REPLACEMENT_REASON_VALUE_REPLACED = BLOCKCHAIN_TX_REPLACEMENT_REASON_VALUE_REPLACED;