@hashspan/core 0.5.0 → 0.7.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
@@ -12,7 +12,7 @@ Payments that another party settles on chain, such as x402 payments, become a **
12
12
  of a `send` span.
13
13
 
14
14
  The core is library-agnostic and read-only: it never signs, sends or fetches anything. Adapters such as
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.
15
+ [`@hashspan/viem`](https://github.com/selimaytac/hashspan/tree/@hashspan/core@0.7.0/packages/viem) call it for you. Use the core directly to instrument any other send path.
16
16
 
17
17
  ## Install
18
18
 
@@ -69,27 +69,28 @@ stays the class name.
69
69
  `tracker.startPayment({ chainId, protocol, payer, recipient, asset, amount })` records a payment that another
70
70
  party settles on chain, such as an x402 facilitator, as a `payment {chainId}` span; end it with
71
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)).
72
+ span ([ADR 0013](https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0013-x402-payments.md)).
73
73
 
74
74
  All three calls accept an explicit parent `Context` as a second argument. An integration that learns about a call only
75
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
76
+ handle method, e.g. `send.end({ hash }, { endTime })` ([ADR 0009](https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0009-telemetry-off-the-call-path.md)). Every method is safe to call: failures inside
77
77
  the instrumentation are reported through `diag` and never thrown into your code. The positional forms of earlier
78
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)).
79
+ until 1.0 ([ADR 0014](https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0014-core-api-boundary.md)).
80
80
 
81
81
  ## Options
82
82
 
83
83
  | Option | Default | Description |
84
84
  |---|---|---|
85
85
  | `tracerProvider` | global provider | Tracer provider to use |
86
+ | `meterProvider` | global provider | Meter provider for the [metrics](#metrics) |
86
87
  | `address` | `'raw'` | `'raw'`, `'hashed'`, `'off'`, or `{ mode: 'hashed', hash: (address) => string }` |
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
+ | `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.7.0/docs/adr/0006-error-privacy.md) |
88
89
  | `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 |
89
90
  | `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 |
90
91
  | `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` |
91
92
  | `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 |
92
- | `redact` | none | `(attributes) => attributes`, runs last on every attribute set, including exception event attributes; if it throws, only non-sensitive identifiers are kept |
93
+ | `redact` | none | `(attributes) => attributes`, runs last on every span attribute set, including exception event attributes, but not on [metrics](#metrics); if it throws, only non-sensitive identifiers are kept |
93
94
  | `linkTtlMs` | `600000` | How long a sent transaction can be linked from its confirmation |
94
95
  | `maxTrackedTransactions` | `10000` | Upper bound on transactions kept for linking |
95
96
 
@@ -99,7 +100,17 @@ Chain id, transaction hash, sender/recipient (per `address` mode), value, nonce,
99
100
  on confirmation, status, block number, gas used, effective gas price, L1 fee, total fee and revert reason. Decoded
100
101
  call arguments are recorded only with `recordFunctionArguments`, and error messages only with `errorMessages`.
101
102
  Attribute definitions:
102
- [docs/semconv.md](https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/semconv.md).
103
+ [docs/semconv.md](https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/semconv.md).
104
+
105
+ ## Metrics
106
+
107
+ With an OpenTelemetry metrics SDK set up (or `meterProvider`), the tracker records three histograms:
108
+ `blockchain.client.send.duration` and `blockchain.client.confirmation.duration` in seconds, and
109
+ `blockchain.client.fee` in wei. Their attributes are the chain and the outcome only, never an address, hash or
110
+ agent identity. The `redact` hook does not run on metrics: a fee it removes from spans is still recorded by
111
+ `blockchain.client.fee`. To keep a histogram out of your backend, drop it with a View of your metrics SDK (drop
112
+ aggregation). Definitions:
113
+ [docs/semconv.md](https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/semconv.md#metrics).
103
114
 
104
115
  ## Privacy notes
105
116
 
@@ -114,9 +125,9 @@ Attribute definitions:
114
125
  propagated, or strip the entries before outbound calls.
115
126
  - **Inbound Baggage can claim an identity.** A caller can send Baggage entries with any agent id. A field set in the
116
127
  `agent` option cannot be overridden that way; to ignore identity from Baggage entirely, set `agentFromBaggage: false`
117
- ([ADR 0011](https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0011-agent-identity-precedence.md)).
118
- - The redaction hook (`redact`) runs last on every attribute set and on exception attributes; use it for anything
119
- else your policy forbids.
128
+ ([ADR 0011](https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0011-agent-identity-precedence.md)).
129
+ - The redaction hook (`redact`) runs last on every span attribute set and on exception attributes; use it for
130
+ anything else your policy forbids. It does not run on [metrics](#metrics), which carry no address or hash.
120
131
  - **Your callbacks' errors go to the diagnostic logger.** If a custom `hash` function or the `redact` hook throws,
121
132
  its error object is logged through the OpenTelemetry `diag` logger, outside the address mode and the redaction
122
133
  hook. Errors of the instrumented call never are. Do not put sensitive values, such as the address being hashed,
package/dist/index.cjs CHANGED
@@ -6,7 +6,7 @@ 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
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
+ * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0011-agent-identity-precedence.md).
10
10
  */
11
11
  function agentAttributes(ctx, identity, fromBaggage = true) {
12
12
  const baggage = fromBaggage ? _opentelemetry_api.propagation.getBaggage(ctx) : void 0;
@@ -22,7 +22,7 @@ function agentAttributes(ctx, identity, fromBaggage = true) {
22
22
  /**
23
23
  * Attribute keys emitted by hashspan.
24
24
  *
25
- * Stability: development. See https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/semconv.md for
25
+ * Stability: development. See https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/semconv.md for
26
26
  * definitions and value types. These names are a public contract: changes follow the deprecation policy in AGENTS.md.
27
27
  */
28
28
  const ATTR_BLOCKCHAIN_SYSTEM = "blockchain.system";
@@ -46,12 +46,12 @@ const ATTR_BLOCKCHAIN_CONTRACT_FUNCTION_NAME = "blockchain.contract.function.nam
46
46
  const ATTR_BLOCKCHAIN_CONTRACT_FUNCTION_SELECTOR = "blockchain.contract.function.selector";
47
47
  /**
48
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.
49
+ * https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0004-privacy-defaults.md.
50
50
  */
51
51
  const ATTR_BLOCKCHAIN_CONTRACT_FUNCTION_ARGUMENTS = "blockchain.contract.function.arguments";
52
52
  /**
53
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.
54
+ * https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0013-x402-payments.md.
55
55
  */
56
56
  const ATTR_BLOCKCHAIN_PAYMENT_PROTOCOL = "blockchain.payment.protocol";
57
57
  const ATTR_BLOCKCHAIN_PAYMENT_PAYER = "blockchain.payment.payer";
@@ -61,6 +61,12 @@ const ATTR_BLOCKCHAIN_PAYMENT_AMOUNT = "blockchain.payment.amount";
61
61
  const ATTR_BLOCKCHAIN_PAYMENT_STATUS = "blockchain.payment.status";
62
62
  /** The amount the settling party reports it settled, e.g. less than the authorized maximum with x402 `upto`. */
63
63
  const ATTR_BLOCKCHAIN_PAYMENT_SETTLED_AMOUNT = "blockchain.payment.settled_amount";
64
+ /**
65
+ * Whether the settlement transaction's receipt carries the payment, as checked by the adapter; absent when no check
66
+ * was possible. See
67
+ * https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0017-x402-payment-verification.md.
68
+ */
69
+ const ATTR_BLOCKCHAIN_PAYMENT_VERIFIED = "blockchain.payment.verified";
64
70
  /** x402's own payment fields. */
65
71
  const ATTR_X402_SCHEME = "x402.scheme";
66
72
  const ATTR_X402_RESOURCE = "x402.resource";
@@ -82,7 +88,7 @@ const BLOCKCHAIN_TX_STATUS_VALUE_REVERTED = "reverted";
82
88
  /**
83
89
  * @deprecated No longer recorded: a confirm span that gave up waiting records `error.type` `timeout` and no
84
90
  * `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.
91
+ * https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0016-timeout-is-an-observer-outcome.md.
86
92
  */
87
93
  const BLOCKCHAIN_TX_STATUS_VALUE_TIMEOUT = "timeout";
88
94
  const BLOCKCHAIN_TX_STATUS_VALUE_REPLACED = "replaced";
@@ -95,6 +101,83 @@ const ATTR_ERROR_TYPE = "error.type";
95
101
  /** Fallback {@link ATTR_ERROR_TYPE} value when the error has no name. */
96
102
  const ERROR_TYPE_VALUE_OTHER = "_OTHER";
97
103
  //#endregion
104
+ //#region src/metrics.ts
105
+ /** Duration of a send: from the start of the sending call until the hash is known or the call failed. */
106
+ const METRIC_BLOCKCHAIN_CLIENT_SEND_DURATION = "blockchain.client.send.duration";
107
+ /** Duration of a confirmation: from the start of the wait until the receipt, a timeout or a failure. */
108
+ const METRIC_BLOCKCHAIN_CLIENT_CONFIRMATION_DURATION = "blockchain.client.confirmation.duration";
109
+ /** Total fee of a mined transaction (execution fee plus L1 data fee), in the chain's smallest unit (wei). */
110
+ const METRIC_BLOCKCHAIN_CLIENT_FEE = "blockchain.client.fee";
111
+ const DURATION_BUCKETS = [
112
+ .05,
113
+ .1,
114
+ .25,
115
+ .5,
116
+ 1,
117
+ 2,
118
+ 5,
119
+ 10,
120
+ 20,
121
+ 30,
122
+ 60,
123
+ 120,
124
+ 300
125
+ ];
126
+ const FEE_BUCKETS = Array.from({ length: 11 }, (_, i) => 10 ** (i + 8));
127
+ /** Milliseconds since the epoch of a span time, as the OpenTelemetry API accepts it. */
128
+ function toEpochMs(time) {
129
+ if (time === void 0) return Date.now();
130
+ if (time instanceof Date) return time.getTime();
131
+ if (Array.isArray(time)) return time[0] * 1e3 + time[1] / 1e6;
132
+ if (typeof time !== "number") return Date.now();
133
+ const origin = globalThis.performance?.timeOrigin;
134
+ return typeof origin === "number" && time < origin ? origin + time : time;
135
+ }
136
+ function createTxMetrics(meterProvider, name, version) {
137
+ let histograms;
138
+ const get = () => {
139
+ histograms ??= (() => {
140
+ const meter = (meterProvider ?? _opentelemetry_api.metrics.getMeterProvider()).getMeter(name, version);
141
+ return {
142
+ send: meter.createHistogram(METRIC_BLOCKCHAIN_CLIENT_SEND_DURATION, {
143
+ unit: "s",
144
+ description: "Duration of sending a transaction, until its hash is known",
145
+ advice: { explicitBucketBoundaries: DURATION_BUCKETS }
146
+ }),
147
+ confirmation: meter.createHistogram(METRIC_BLOCKCHAIN_CLIENT_CONFIRMATION_DURATION, {
148
+ unit: "s",
149
+ description: "Duration of waiting for a transaction receipt",
150
+ advice: { explicitBucketBoundaries: DURATION_BUCKETS }
151
+ }),
152
+ fee: meter.createHistogram(METRIC_BLOCKCHAIN_CLIENT_FEE, {
153
+ unit: "{wei}",
154
+ description: "Total fee of a mined transaction",
155
+ advice: { explicitBucketBoundaries: FEE_BUCKETS }
156
+ })
157
+ };
158
+ })();
159
+ return histograms;
160
+ };
161
+ const record = (what, run) => {
162
+ try {
163
+ run();
164
+ } catch (error) {
165
+ _opentelemetry_api.diag.error(`hashspan: failed to record the ${what} metric`, error);
166
+ }
167
+ };
168
+ return {
169
+ sendDuration: (seconds, attributes) => record("send duration", () => {
170
+ if (seconds >= 0) get().send.record(seconds, attributes);
171
+ }),
172
+ confirmationDuration: (seconds, attributes) => record("confirmation duration", () => {
173
+ if (seconds >= 0) get().confirmation.record(seconds, attributes);
174
+ }),
175
+ fee: (wei, attributes) => record("fee", () => {
176
+ if (wei >= 0n) get().fee.record(Number(wei), attributes);
177
+ })
178
+ };
179
+ }
180
+ //#endregion
98
181
  //#region src/confirm-registry.ts
99
182
  /**
100
183
  * Bounded registry from (chainId, tx hash) to its in-flight confirm span, or to "settled" for a while after a
@@ -380,7 +463,7 @@ function sanitizeResource(resource) {
380
463
  }
381
464
  //#endregion
382
465
  //#region src/version.ts
383
- const VERSION = "0.5.0";
466
+ const VERSION = "0.7.0";
384
467
  //#endregion
385
468
  //#region src/tracker.ts
386
469
  const INSTRUMENTATION_NAME = "@hashspan/core";
@@ -402,6 +485,7 @@ const NON_SENSITIVE_KEYS = /* @__PURE__ */ new Set([
402
485
  ATTR_BLOCKCHAIN_TX_REPLACEMENT_REASON,
403
486
  ATTR_BLOCKCHAIN_PAYMENT_PROTOCOL,
404
487
  ATTR_BLOCKCHAIN_PAYMENT_STATUS,
488
+ ATTR_BLOCKCHAIN_PAYMENT_VERIFIED,
405
489
  ATTR_ERROR_TYPE,
406
490
  ATTR_EXCEPTION_TYPE
407
491
  ]);
@@ -430,7 +514,8 @@ const noopSend = (parent) => ({
430
514
  const NOOP_PAYMENT = {
431
515
  end: () => {},
432
516
  fail: () => {},
433
- timeout: () => {}
517
+ timeout: () => {},
518
+ link: () => {}
434
519
  };
435
520
  const NOOP_CONFIRM = {
436
521
  end: () => {},
@@ -505,7 +590,7 @@ function reportedErrorType(error, options) {
505
590
  }
506
591
  /**
507
592
  * 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
593
+ * `@opentelemetry/api` (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/semconv.md). It makes
509
594
  * no network calls; the caller passes hashes and receipts. Its methods and handles never throw: failures are logged
510
595
  * via `diag`, and a method that fails returns a handle that records nothing.
511
596
  */
@@ -521,6 +606,14 @@ function createTxTracker(options = {}) {
521
606
  const formatAddress = safely("configure address mode", () => resolveAddressFormatter(options.address), OFF_ADDRESS_FORMATTER);
522
607
  const errorMessages = safely("configure error message mode", () => resolveErrorMessageMode(options.errorMessages), "off");
523
608
  const paymentResource = safely("configure payment resource mode", () => resolvePaymentResourceMode(options.paymentResource), "off");
609
+ const txMetrics = createTxMetrics(options.meterProvider, INSTRUMENTATION_NAME, VERSION);
610
+ /** Attributes of a metric: low-cardinality only, never an address, hash or agent identity. */
611
+ const metricAttributes = (chainId, extra = {}) => ({
612
+ [ATTR_BLOCKCHAIN_SYSTEM]: "evm",
613
+ [ATTR_BLOCKCHAIN_CHAIN_ID]: chainId,
614
+ ...extra
615
+ });
616
+ const secondsSince = (startMs, endTime) => (toEpochMs(endTime) - startMs) / 1e3;
524
617
  let tracer;
525
618
  const getTracer = () => {
526
619
  tracer ??= (options.tracerProvider ?? _opentelemetry_api.trace.getTracerProvider()).getTracer(INSTRUMENTATION_NAME, VERSION);
@@ -545,7 +638,7 @@ function createTxTracker(options = {}) {
545
638
  /**
546
639
  * Exception event attributes for `error`, per the error message mode. The error object itself is never handed to
547
640
  * 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).
641
+ * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0006-error-privacy.md).
549
642
  */
550
643
  const exceptionAttributes = (type, error) => {
551
644
  const attributes = { [ATTR_EXCEPTION_TYPE]: type };
@@ -578,6 +671,7 @@ function createTxTracker(options = {}) {
578
671
  code: _opentelemetry_api.SpanStatusCode.ERROR,
579
672
  ...message !== void 0 ? { message } : {}
580
673
  });
674
+ return type;
581
675
  };
582
676
  /** Ends a span exactly once; the span is always ended even if recording attributes fails. */
583
677
  const finisher = (span) => {
@@ -629,6 +723,8 @@ function createTxTracker(options = {}) {
629
723
  ...input.startTime !== void 0 ? { startTime: input.startTime } : {}
630
724
  }, parent);
631
725
  const finish = finisher(span);
726
+ const startMs = toEpochMs(input.startTime);
727
+ const recordSend = (endTime, errorType) => txMetrics.sendDuration(secondsSince(startMs, endTime), metricAttributes(input.chainId, errorType === void 0 ? {} : { [ATTR_ERROR_TYPE]: errorType }));
632
728
  return {
633
729
  context: _opentelemetry_api.trace.setSpan(parent, span),
634
730
  end: (result, second) => finish("record transaction hash", () => {
@@ -642,10 +738,11 @@ function createTxTracker(options = {}) {
642
738
  parent
643
739
  });
644
740
  span.setAttributes(redact({ [ATTR_BLOCKCHAIN_TX_HASH]: hash }));
741
+ recordSend(handleOptions(second).endTime);
645
742
  }, handleOptions(second).endTime),
646
743
  fail: (error, second, third) => {
647
744
  const options = handleOptions(second, third);
648
- finish("record send failure", () => markError(span, reportedErrorType(error, options), error, errorType(error)), options.endTime);
745
+ finish("record send failure", () => recordSend(options.endTime, markError(span, reportedErrorType(error, options), error, errorType(error))), options.endTime);
649
746
  }
650
747
  };
651
748
  };
@@ -681,6 +778,8 @@ function createTxTracker(options = {}) {
681
778
  ...explicitStart !== void 0 ? { startTime: explicitStart } : {}
682
779
  }, parent);
683
780
  const finish = finisher(span);
781
+ const startMs = toEpochMs(explicitStart);
782
+ const recordConfirmation = (endTime, outcome) => txMetrics.confirmationDuration(secondsSince(startMs, endTime), metricAttributes(input.chainId, outcome));
684
783
  return {
685
784
  active: 0,
686
785
  ended: false,
@@ -690,11 +789,16 @@ function createTxTracker(options = {}) {
690
789
  links: [{ context: span.spanContext() }, ...sent ? [{ context: sent.spanContext }] : []]
691
790
  },
692
791
  receipt: (receipt, endTime) => finish("record receipt", () => {
693
- span.setAttributes(redact(receiptAttributes(receipt)));
792
+ const attributes = receiptAttributes(receipt);
793
+ span.setAttributes(redact(attributes));
694
794
  if (receipt.status === "reverted") markError(span, BLOCKCHAIN_TX_STATUS_VALUE_REVERTED);
795
+ const status = { [ATTR_BLOCKCHAIN_TX_STATUS]: attributes[ATTR_BLOCKCHAIN_TX_STATUS] };
796
+ recordConfirmation(endTime, status);
797
+ const fee = attributes[ATTR_BLOCKCHAIN_TX_FEE];
798
+ if (typeof fee === "string") txMetrics.fee(BigInt(fee), metricAttributes(input.chainId, status));
695
799
  }, endTime),
696
- timeout: (endTime) => finish("record confirmation timeout", () => markError(span, OBSERVER_TIMEOUT), endTime),
697
- fail: (error, endTime) => finish("record confirmation failure", () => markError(span, errorType(error), error), endTime),
800
+ timeout: (endTime) => finish("record confirmation timeout", () => recordConfirmation(endTime, { [ATTR_ERROR_TYPE]: markError(span, OBSERVER_TIMEOUT) }), endTime),
801
+ fail: (error, endTime) => finish("record confirmation failure", () => recordConfirmation(endTime, { [ATTR_ERROR_TYPE]: markError(span, errorType(error), error) }), endTime),
698
802
  replaced: (hash, reason, endTime) => finish("record replacement", () => {
699
803
  const attributes = {
700
804
  [ATTR_BLOCKCHAIN_TX_STATUS]: BLOCKCHAIN_TX_STATUS_VALUE_REPLACED,
@@ -702,8 +806,9 @@ function createTxTracker(options = {}) {
702
806
  };
703
807
  if (reason !== void 0 && REPLACEMENT_REASONS.has(reason)) attributes[ATTR_BLOCKCHAIN_TX_REPLACEMENT_REASON] = reason;
704
808
  span.setAttributes(redact(attributes));
809
+ recordConfirmation(endTime, { [ATTR_BLOCKCHAIN_TX_STATUS]: BLOCKCHAIN_TX_STATUS_VALUE_REPLACED });
705
810
  }, endTime),
706
- unattributable: (endTime) => finish("record unattributable receipt", () => markError(span, ERROR_TYPE_VALUE_OTHER), endTime)
811
+ unattributable: (endTime) => finish("record unattributable receipt", () => recordConfirmation(endTime, { [ATTR_ERROR_TYPE]: markError(span, ERROR_TYPE_VALUE_OTHER) }), endTime)
707
812
  };
708
813
  };
709
814
  /**
@@ -725,7 +830,7 @@ function createTxTracker(options = {}) {
725
830
  };
726
831
  /**
727
832
  * 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).
833
+ * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0008-replaced-transactions.md).
729
834
  */
730
835
  const endWithReceipt = (chainId, hash, shared, receipt, endTime) => {
731
836
  const mined = receipt.transactionHash;
@@ -815,6 +920,15 @@ function createTxTracker(options = {}) {
815
920
  ...input.startTime !== void 0 ? { startTime: input.startTime } : {}
816
921
  }, parent);
817
922
  const finish = finisher(span);
923
+ /** Links the confirm span of `hash` to this payment span, unless the tracker already links that hash. */
924
+ const linkHash = (hash) => {
925
+ if (typeof hash !== "string" || !TX_HASH.test(hash)) return false;
926
+ if (!links.get(input.chainId, hash)) links.set(input.chainId, hash, {
927
+ spanContext: span.spanContext(),
928
+ parent
929
+ });
930
+ return true;
931
+ };
818
932
  const recordSettlement = (settlement) => {
819
933
  const status = settlement.status;
820
934
  if (!PAYMENT_STATUSES.has(status)) {
@@ -823,19 +937,15 @@ function createTxTracker(options = {}) {
823
937
  }
824
938
  const settled = { [ATTR_BLOCKCHAIN_PAYMENT_STATUS]: status };
825
939
  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
- }
940
+ if (linkHash(hash)) settled[ATTR_BLOCKCHAIN_TX_HASH] = hash;
833
941
  if (!knownPayer) setPaymentAddress(settled, ATTR_BLOCKCHAIN_PAYMENT_PAYER, settlement.payer);
834
942
  const settledAmount = amount(settlement.amount);
835
943
  if (settledAmount !== void 0) {
836
944
  settled[ATTR_BLOCKCHAIN_PAYMENT_SETTLED_AMOUNT] = settledAmount;
837
945
  if (paid === void 0) settled[ATTR_BLOCKCHAIN_PAYMENT_AMOUNT] = settledAmount;
838
946
  }
947
+ const verified = settlement.verified;
948
+ if (typeof verified === "boolean") settled[ATTR_BLOCKCHAIN_PAYMENT_VERIFIED] = verified;
839
949
  span.setAttributes(redact(settled));
840
950
  if (status === "failed") markError(span, identifier(settlement.errorReason) ?? "_OTHER");
841
951
  };
@@ -845,7 +955,8 @@ function createTxTracker(options = {}) {
845
955
  const read = handleOptions(options);
846
956
  finish("record payment failure", () => markError(span, reportedErrorType(error, read), error, errorType(error)), read.endTime);
847
957
  },
848
- timeout: (options) => finish("record payment timeout", () => markError(span, OBSERVER_TIMEOUT), handleOptions(options).endTime)
958
+ timeout: (options) => finish("record payment timeout", () => markError(span, OBSERVER_TIMEOUT), handleOptions(options).endTime),
959
+ link: (hash) => safely("link the payment span", () => void linkHash(hash), void 0)
849
960
  };
850
961
  };
851
962
  return {
@@ -868,6 +979,7 @@ exports.ATTR_BLOCKCHAIN_PAYMENT_PROTOCOL = ATTR_BLOCKCHAIN_PAYMENT_PROTOCOL;
868
979
  exports.ATTR_BLOCKCHAIN_PAYMENT_RECIPIENT = ATTR_BLOCKCHAIN_PAYMENT_RECIPIENT;
869
980
  exports.ATTR_BLOCKCHAIN_PAYMENT_SETTLED_AMOUNT = ATTR_BLOCKCHAIN_PAYMENT_SETTLED_AMOUNT;
870
981
  exports.ATTR_BLOCKCHAIN_PAYMENT_STATUS = ATTR_BLOCKCHAIN_PAYMENT_STATUS;
982
+ exports.ATTR_BLOCKCHAIN_PAYMENT_VERIFIED = ATTR_BLOCKCHAIN_PAYMENT_VERIFIED;
871
983
  exports.ATTR_BLOCKCHAIN_SYSTEM = ATTR_BLOCKCHAIN_SYSTEM;
872
984
  exports.ATTR_BLOCKCHAIN_TX_EFFECTIVE_GAS_PRICE = ATTR_BLOCKCHAIN_TX_EFFECTIVE_GAS_PRICE;
873
985
  exports.ATTR_BLOCKCHAIN_TX_FEE = ATTR_BLOCKCHAIN_TX_FEE;
@@ -903,5 +1015,8 @@ exports.BLOCKCHAIN_TX_STATUS_VALUE_REVERTED = BLOCKCHAIN_TX_STATUS_VALUE_REVERTE
903
1015
  exports.BLOCKCHAIN_TX_STATUS_VALUE_SUCCESS = BLOCKCHAIN_TX_STATUS_VALUE_SUCCESS;
904
1016
  exports.BLOCKCHAIN_TX_STATUS_VALUE_TIMEOUT = BLOCKCHAIN_TX_STATUS_VALUE_TIMEOUT;
905
1017
  exports.ERROR_TYPE_VALUE_OTHER = ERROR_TYPE_VALUE_OTHER;
1018
+ exports.METRIC_BLOCKCHAIN_CLIENT_CONFIRMATION_DURATION = METRIC_BLOCKCHAIN_CLIENT_CONFIRMATION_DURATION;
1019
+ exports.METRIC_BLOCKCHAIN_CLIENT_FEE = METRIC_BLOCKCHAIN_CLIENT_FEE;
1020
+ exports.METRIC_BLOCKCHAIN_CLIENT_SEND_DURATION = METRIC_BLOCKCHAIN_CLIENT_SEND_DURATION;
906
1021
  exports.VERSION = VERSION;
907
1022
  exports.createTxTracker = createTxTracker;
package/dist/index.d.cts CHANGED
@@ -1,13 +1,13 @@
1
- import { Attributes, Context, TimeInput, TracerProvider } from "@opentelemetry/api";
1
+ import { Attributes, Context, MeterProvider, TimeInput, TracerProvider } from "@opentelemetry/api";
2
2
  //#region src/types.d.ts
3
3
  /**
4
4
  * How wallet addresses are recorded. See
5
- * https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0004-privacy-defaults.md.
5
+ * https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0004-privacy-defaults.md.
6
6
  */
7
7
  type AddressMode = "raw" | "hashed" | "off";
8
8
  /**
9
9
  * How error messages are recorded on exception events and span status. See
10
- * https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0006-error-privacy.md.
10
+ * https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0006-error-privacy.md.
11
11
  * - `off`: error type only
12
12
  * - `sanitized`: first line, addresses per address mode, other long hex data removed
13
13
  * - `raw`: full message and stack trace, as thrown
@@ -43,6 +43,11 @@ interface AgentIdentity {
43
43
  interface TxTrackerOptions {
44
44
  /** Defaults to the globally registered tracer provider. */
45
45
  tracerProvider?: TracerProvider | undefined;
46
+ /**
47
+ * Meter provider for the send, confirmation and fee histograms. Defaults to the globally registered one, which
48
+ * records nothing until an OpenTelemetry metrics SDK is set up.
49
+ */
50
+ meterProvider?: MeterProvider | undefined;
46
51
  /** Address recording mode. Default: `raw`. */
47
52
  address?: AddressMode | AddressOptions | undefined;
48
53
  /**
@@ -61,7 +66,7 @@ interface TxTrackerOptions {
61
66
  /**
62
67
  * Agent identity. A field set here always wins; fields left unset are taken from the Baggage entries
63
68
  * `gen_ai.agent.id` / `gen_ai.agent.name` unless `agentFromBaggage` is false
64
- * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0011-agent-identity-precedence.md).
69
+ * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0011-agent-identity-precedence.md).
65
70
  */
66
71
  agent?: AgentIdentity | undefined;
67
72
  /**
@@ -75,8 +80,8 @@ interface TxTrackerOptions {
75
80
  * If it throws or returns something other than an attributes object, the tracker fails closed and records only
76
81
  * `blockchain.system`, `blockchain.chain.id`, `blockchain.operation.name`, `blockchain.tx.hash`,
77
82
  * `blockchain.tx.status`, `blockchain.tx.replacement.hash`, `blockchain.tx.replacement.reason`,
78
- * `blockchain.payment.protocol`, `blockchain.payment.status`, `error.type` and `exception.type`, and logs the
79
- * failure via `diag`.
83
+ * `blockchain.payment.protocol`, `blockchain.payment.status`, `blockchain.payment.verified`, `error.type` and
84
+ * `exception.type`, and logs the failure via `diag`.
80
85
  */
81
86
  redact?: ((attributes: Attributes) => Attributes) | undefined;
82
87
  /** How long a sent transaction can be linked from its confirmation. Default: 10 minutes. */
@@ -103,7 +108,7 @@ interface SendInput {
103
108
  functionArguments?: readonly unknown[] | undefined;
104
109
  /**
105
110
  * When the send started, for adapters that record it after the fact
106
- * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0009-telemetry-off-the-call-path.md).
111
+ * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0009-telemetry-off-the-call-path.md).
107
112
  * Omit it otherwise: with an explicit start time, the SDK measures the span by the wall clock, so pass the end time
108
113
  * to the handle too.
109
114
  */
@@ -112,14 +117,14 @@ interface SendInput {
112
117
  /**
113
118
  * Ends a send span. Only the first call counts; methods never throw.
114
119
  * Produced by the tracker only; methods may be added in minor releases
115
- * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0014-core-api-boundary.md).
120
+ * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0014-core-api-boundary.md).
116
121
  */
117
122
  interface SendHandle {
118
123
  /**
119
124
  * The parent context with the send span set. Run the call that sends the transaction in it, e.g.
120
125
  * `await context.with(send.context, () => sendSomehow())`, so that spans of wallet, RPC or HTTP instrumentation
121
126
  * nest under the send span
122
- * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0015-send-span-as-active-context.md).
127
+ * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0015-send-span-as-active-context.md).
123
128
  * Run only that call in it: a confirm span started in it becomes a child of the send span.
124
129
  */
125
130
  readonly context: Context;
@@ -182,7 +187,7 @@ interface ReceiptLike {
182
187
  /**
183
188
  * Hash of the mined transaction. When it differs from the awaited hash, the awaited transaction was replaced: its
184
189
  * confirm span ends as `replaced` and the receipt is recorded for this hash
185
- * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0008-replaced-transactions.md).
190
+ * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0008-replaced-transactions.md).
186
191
  */
187
192
  transactionHash?: string | undefined;
188
193
  /** Replacement reason reported by the library, when {@link transactionHash} differs from the awaited hash. */
@@ -192,7 +197,7 @@ interface ReceiptLike {
192
197
  * One wait for a transaction's receipt, joined to the transaction's shared confirm span. Only the first call counts;
193
198
  * methods never throw.
194
199
  * Produced by the tracker only; methods may be added in minor releases
195
- * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0014-core-api-boundary.md).
200
+ * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0014-core-api-boundary.md).
196
201
  */
197
202
  interface ConfirmHandle {
198
203
  /** Ends the shared confirm span with the receipt, for every handle of the transaction. */
@@ -216,7 +221,7 @@ interface ConfirmHandle {
216
221
  }
217
222
  /**
218
223
  * A payment the agent authorizes and another party settles on chain, e.g. an x402 facilitator
219
- * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0013-x402-payments.md).
224
+ * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0013-x402-payments.md).
220
225
  * Values often come from a remote server: addresses, amounts and identifiers that are malformed are not recorded.
221
226
  */
222
227
  interface PaymentInput {
@@ -257,13 +262,18 @@ interface PaymentSettlement {
257
262
  * `blockchain.payment.amount` when the input had none.
258
263
  */
259
264
  amount?: bigint | string | undefined;
265
+ /**
266
+ * Whether the settlement transaction's receipt carries this payment, as the adapter checked it from the payer's own
267
+ * data; recorded as `blockchain.payment.verified`. Leave it unset when no check was possible.
268
+ */
269
+ verified?: boolean | undefined;
260
270
  /** Why a `failed` settlement failed, recorded as `error.type` if it is a short identifier, else `_OTHER`. */
261
271
  errorReason?: string | undefined;
262
272
  }
263
273
  /**
264
274
  * Ends a payment span. Only the first call counts; methods never throw.
265
275
  * Produced by the tracker only; methods may be added in minor releases
266
- * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0014-core-api-boundary.md).
276
+ * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0014-core-api-boundary.md).
267
277
  */
268
278
  interface PaymentHandle {
269
279
  /** Ends the payment span with its settlement. */
@@ -279,6 +289,12 @@ interface PaymentHandle {
279
289
  * learned, e.g. no response arrived before the authorization expired.
280
290
  */
281
291
  timeout(options?: EndOptions): void;
292
+ /**
293
+ * Makes the confirm span of the settling transaction `hash` link to this payment span before it ends, for an
294
+ * adapter that ends it only after checking that transaction's receipt (ADR 0017). A hash this tracker already links,
295
+ * such as one of its own sends, keeps its link; `end` with a hash links it as well.
296
+ */
297
+ link(hash: string): void;
282
298
  }
283
299
  //#endregion
284
300
  //#region src/agent.d.ts
@@ -289,7 +305,7 @@ export declare const ATTR_GEN_AI_AGENT_NAME: "gen_ai.agent.name";
289
305
  /**
290
306
  * Attribute keys emitted by hashspan.
291
307
  *
292
- * Stability: development. See https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/semconv.md for
308
+ * Stability: development. See https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/semconv.md for
293
309
  * definitions and value types. These names are a public contract: changes follow the deprecation policy in AGENTS.md.
294
310
  */
295
311
  export declare const ATTR_BLOCKCHAIN_SYSTEM: "blockchain.system";
@@ -313,12 +329,12 @@ export declare const ATTR_BLOCKCHAIN_CONTRACT_FUNCTION_NAME: "blockchain.contrac
313
329
  export declare const ATTR_BLOCKCHAIN_CONTRACT_FUNCTION_SELECTOR: "blockchain.contract.function.selector";
314
330
  /**
315
331
  * Opt-in: decoded call arguments as a JSON array. See
316
- * https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0004-privacy-defaults.md.
332
+ * https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0004-privacy-defaults.md.
317
333
  */
318
334
  export declare const ATTR_BLOCKCHAIN_CONTRACT_FUNCTION_ARGUMENTS: "blockchain.contract.function.arguments";
319
335
  /**
320
336
  * Payments settled on chain by a party other than the agent, e.g. an x402 facilitator. See
321
- * https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0013-x402-payments.md.
337
+ * https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0013-x402-payments.md.
322
338
  */
323
339
  export declare const ATTR_BLOCKCHAIN_PAYMENT_PROTOCOL: "blockchain.payment.protocol";
324
340
  export declare const ATTR_BLOCKCHAIN_PAYMENT_PAYER: "blockchain.payment.payer";
@@ -328,6 +344,12 @@ export declare const ATTR_BLOCKCHAIN_PAYMENT_AMOUNT: "blockchain.payment.amount"
328
344
  export declare const ATTR_BLOCKCHAIN_PAYMENT_STATUS: "blockchain.payment.status";
329
345
  /** The amount the settling party reports it settled, e.g. less than the authorized maximum with x402 `upto`. */
330
346
  export declare const ATTR_BLOCKCHAIN_PAYMENT_SETTLED_AMOUNT: "blockchain.payment.settled_amount";
347
+ /**
348
+ * Whether the settlement transaction's receipt carries the payment, as checked by the adapter; absent when no check
349
+ * was possible. See
350
+ * https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0017-x402-payment-verification.md.
351
+ */
352
+ export declare const ATTR_BLOCKCHAIN_PAYMENT_VERIFIED: "blockchain.payment.verified";
331
353
  /** x402's own payment fields. */
332
354
  export declare const ATTR_X402_SCHEME: "x402.scheme";
333
355
  export declare const ATTR_X402_RESOURCE: "x402.resource";
@@ -349,7 +371,7 @@ export declare const BLOCKCHAIN_TX_STATUS_VALUE_REVERTED: "reverted";
349
371
  /**
350
372
  * @deprecated No longer recorded: a confirm span that gave up waiting records `error.type` `timeout` and no
351
373
  * `blockchain.tx.status`. The constant is removed in 1.0. See
352
- * https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0016-timeout-is-an-observer-outcome.md.
374
+ * https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0016-timeout-is-an-observer-outcome.md.
353
375
  */
354
376
  export declare const BLOCKCHAIN_TX_STATUS_VALUE_TIMEOUT: "timeout";
355
377
  export declare const BLOCKCHAIN_TX_STATUS_VALUE_REPLACED: "replaced";
@@ -362,11 +384,19 @@ export declare const ATTR_ERROR_TYPE: "error.type";
362
384
  /** Fallback {@link ATTR_ERROR_TYPE} value when the error has no name. */
363
385
  export declare const ERROR_TYPE_VALUE_OTHER: "_OTHER";
364
386
  //#endregion
387
+ //#region src/metrics.d.ts
388
+ /** Duration of a send: from the start of the sending call until the hash is known or the call failed. */
389
+ export declare const METRIC_BLOCKCHAIN_CLIENT_SEND_DURATION: "blockchain.client.send.duration";
390
+ /** Duration of a confirmation: from the start of the wait until the receipt, a timeout or a failure. */
391
+ export declare const METRIC_BLOCKCHAIN_CLIENT_CONFIRMATION_DURATION: "blockchain.client.confirmation.duration";
392
+ /** Total fee of a mined transaction (execution fee plus L1 data fee), in the chain's smallest unit (wei). */
393
+ export declare const METRIC_BLOCKCHAIN_CLIENT_FEE: "blockchain.client.fee";
394
+ //#endregion
365
395
  //#region src/tracker.d.ts
366
396
  /**
367
397
  * Records transactions and payments as spans. Obtain one from {@link createTxTracker}: it is not meant to be
368
398
  * implemented, and members may be added to it and to its handles in minor releases
369
- * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0014-core-api-boundary.md).
399
+ * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0014-core-api-boundary.md).
370
400
  */
371
401
  interface TxTracker {
372
402
  /**
@@ -384,7 +414,7 @@ interface TxTracker {
384
414
  /**
385
415
  * Starts a `payment` span as a child of `parent` (default: the active context), for a payment that another party
386
416
  * settles on chain
387
- * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0013-x402-payments.md). Call
417
+ * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0013-x402-payments.md). Call
388
418
  * `end(settlement)` with the settlement, or `fail(error)`. A settlement with a hash links the transaction's confirm
389
419
  * span to this span, as a send span would.
390
420
  */
@@ -392,7 +422,7 @@ interface TxTracker {
392
422
  }
393
423
  /**
394
424
  * Creates a tracker that records transactions as `send` and `confirm` spans, and payments as `payment` spans, with
395
- * `@opentelemetry/api` (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/semconv.md). It makes
425
+ * `@opentelemetry/api` (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/semconv.md). It makes
396
426
  * no network calls; the caller passes hashes and receipts. Its methods and handles never throw: failures are logged
397
427
  * via `diag`, and a method that fails returns a handle that records nothing.
398
428
  */
package/dist/index.d.mts CHANGED
@@ -1,13 +1,13 @@
1
- import { Attributes, Context, TimeInput, TracerProvider } from "@opentelemetry/api";
1
+ import { Attributes, Context, MeterProvider, TimeInput, TracerProvider } from "@opentelemetry/api";
2
2
  //#region src/types.d.ts
3
3
  /**
4
4
  * How wallet addresses are recorded. See
5
- * https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0004-privacy-defaults.md.
5
+ * https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0004-privacy-defaults.md.
6
6
  */
7
7
  type AddressMode = "raw" | "hashed" | "off";
8
8
  /**
9
9
  * How error messages are recorded on exception events and span status. See
10
- * https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0006-error-privacy.md.
10
+ * https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0006-error-privacy.md.
11
11
  * - `off`: error type only
12
12
  * - `sanitized`: first line, addresses per address mode, other long hex data removed
13
13
  * - `raw`: full message and stack trace, as thrown
@@ -43,6 +43,11 @@ interface AgentIdentity {
43
43
  interface TxTrackerOptions {
44
44
  /** Defaults to the globally registered tracer provider. */
45
45
  tracerProvider?: TracerProvider | undefined;
46
+ /**
47
+ * Meter provider for the send, confirmation and fee histograms. Defaults to the globally registered one, which
48
+ * records nothing until an OpenTelemetry metrics SDK is set up.
49
+ */
50
+ meterProvider?: MeterProvider | undefined;
46
51
  /** Address recording mode. Default: `raw`. */
47
52
  address?: AddressMode | AddressOptions | undefined;
48
53
  /**
@@ -61,7 +66,7 @@ interface TxTrackerOptions {
61
66
  /**
62
67
  * Agent identity. A field set here always wins; fields left unset are taken from the Baggage entries
63
68
  * `gen_ai.agent.id` / `gen_ai.agent.name` unless `agentFromBaggage` is false
64
- * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0011-agent-identity-precedence.md).
69
+ * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0011-agent-identity-precedence.md).
65
70
  */
66
71
  agent?: AgentIdentity | undefined;
67
72
  /**
@@ -75,8 +80,8 @@ interface TxTrackerOptions {
75
80
  * If it throws or returns something other than an attributes object, the tracker fails closed and records only
76
81
  * `blockchain.system`, `blockchain.chain.id`, `blockchain.operation.name`, `blockchain.tx.hash`,
77
82
  * `blockchain.tx.status`, `blockchain.tx.replacement.hash`, `blockchain.tx.replacement.reason`,
78
- * `blockchain.payment.protocol`, `blockchain.payment.status`, `error.type` and `exception.type`, and logs the
79
- * failure via `diag`.
83
+ * `blockchain.payment.protocol`, `blockchain.payment.status`, `blockchain.payment.verified`, `error.type` and
84
+ * `exception.type`, and logs the failure via `diag`.
80
85
  */
81
86
  redact?: ((attributes: Attributes) => Attributes) | undefined;
82
87
  /** How long a sent transaction can be linked from its confirmation. Default: 10 minutes. */
@@ -103,7 +108,7 @@ interface SendInput {
103
108
  functionArguments?: readonly unknown[] | undefined;
104
109
  /**
105
110
  * When the send started, for adapters that record it after the fact
106
- * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0009-telemetry-off-the-call-path.md).
111
+ * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0009-telemetry-off-the-call-path.md).
107
112
  * Omit it otherwise: with an explicit start time, the SDK measures the span by the wall clock, so pass the end time
108
113
  * to the handle too.
109
114
  */
@@ -112,14 +117,14 @@ interface SendInput {
112
117
  /**
113
118
  * Ends a send span. Only the first call counts; methods never throw.
114
119
  * Produced by the tracker only; methods may be added in minor releases
115
- * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0014-core-api-boundary.md).
120
+ * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0014-core-api-boundary.md).
116
121
  */
117
122
  interface SendHandle {
118
123
  /**
119
124
  * The parent context with the send span set. Run the call that sends the transaction in it, e.g.
120
125
  * `await context.with(send.context, () => sendSomehow())`, so that spans of wallet, RPC or HTTP instrumentation
121
126
  * nest under the send span
122
- * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0015-send-span-as-active-context.md).
127
+ * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0015-send-span-as-active-context.md).
123
128
  * Run only that call in it: a confirm span started in it becomes a child of the send span.
124
129
  */
125
130
  readonly context: Context;
@@ -182,7 +187,7 @@ interface ReceiptLike {
182
187
  /**
183
188
  * Hash of the mined transaction. When it differs from the awaited hash, the awaited transaction was replaced: its
184
189
  * confirm span ends as `replaced` and the receipt is recorded for this hash
185
- * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0008-replaced-transactions.md).
190
+ * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0008-replaced-transactions.md).
186
191
  */
187
192
  transactionHash?: string | undefined;
188
193
  /** Replacement reason reported by the library, when {@link transactionHash} differs from the awaited hash. */
@@ -192,7 +197,7 @@ interface ReceiptLike {
192
197
  * One wait for a transaction's receipt, joined to the transaction's shared confirm span. Only the first call counts;
193
198
  * methods never throw.
194
199
  * Produced by the tracker only; methods may be added in minor releases
195
- * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0014-core-api-boundary.md).
200
+ * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0014-core-api-boundary.md).
196
201
  */
197
202
  interface ConfirmHandle {
198
203
  /** Ends the shared confirm span with the receipt, for every handle of the transaction. */
@@ -216,7 +221,7 @@ interface ConfirmHandle {
216
221
  }
217
222
  /**
218
223
  * A payment the agent authorizes and another party settles on chain, e.g. an x402 facilitator
219
- * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0013-x402-payments.md).
224
+ * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0013-x402-payments.md).
220
225
  * Values often come from a remote server: addresses, amounts and identifiers that are malformed are not recorded.
221
226
  */
222
227
  interface PaymentInput {
@@ -257,13 +262,18 @@ interface PaymentSettlement {
257
262
  * `blockchain.payment.amount` when the input had none.
258
263
  */
259
264
  amount?: bigint | string | undefined;
265
+ /**
266
+ * Whether the settlement transaction's receipt carries this payment, as the adapter checked it from the payer's own
267
+ * data; recorded as `blockchain.payment.verified`. Leave it unset when no check was possible.
268
+ */
269
+ verified?: boolean | undefined;
260
270
  /** Why a `failed` settlement failed, recorded as `error.type` if it is a short identifier, else `_OTHER`. */
261
271
  errorReason?: string | undefined;
262
272
  }
263
273
  /**
264
274
  * Ends a payment span. Only the first call counts; methods never throw.
265
275
  * Produced by the tracker only; methods may be added in minor releases
266
- * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0014-core-api-boundary.md).
276
+ * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0014-core-api-boundary.md).
267
277
  */
268
278
  interface PaymentHandle {
269
279
  /** Ends the payment span with its settlement. */
@@ -279,6 +289,12 @@ interface PaymentHandle {
279
289
  * learned, e.g. no response arrived before the authorization expired.
280
290
  */
281
291
  timeout(options?: EndOptions): void;
292
+ /**
293
+ * Makes the confirm span of the settling transaction `hash` link to this payment span before it ends, for an
294
+ * adapter that ends it only after checking that transaction's receipt (ADR 0017). A hash this tracker already links,
295
+ * such as one of its own sends, keeps its link; `end` with a hash links it as well.
296
+ */
297
+ link(hash: string): void;
282
298
  }
283
299
  //#endregion
284
300
  //#region src/agent.d.ts
@@ -289,7 +305,7 @@ export declare const ATTR_GEN_AI_AGENT_NAME: "gen_ai.agent.name";
289
305
  /**
290
306
  * Attribute keys emitted by hashspan.
291
307
  *
292
- * Stability: development. See https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/semconv.md for
308
+ * Stability: development. See https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/semconv.md for
293
309
  * definitions and value types. These names are a public contract: changes follow the deprecation policy in AGENTS.md.
294
310
  */
295
311
  export declare const ATTR_BLOCKCHAIN_SYSTEM: "blockchain.system";
@@ -313,12 +329,12 @@ export declare const ATTR_BLOCKCHAIN_CONTRACT_FUNCTION_NAME: "blockchain.contrac
313
329
  export declare const ATTR_BLOCKCHAIN_CONTRACT_FUNCTION_SELECTOR: "blockchain.contract.function.selector";
314
330
  /**
315
331
  * Opt-in: decoded call arguments as a JSON array. See
316
- * https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0004-privacy-defaults.md.
332
+ * https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0004-privacy-defaults.md.
317
333
  */
318
334
  export declare const ATTR_BLOCKCHAIN_CONTRACT_FUNCTION_ARGUMENTS: "blockchain.contract.function.arguments";
319
335
  /**
320
336
  * Payments settled on chain by a party other than the agent, e.g. an x402 facilitator. See
321
- * https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0013-x402-payments.md.
337
+ * https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0013-x402-payments.md.
322
338
  */
323
339
  export declare const ATTR_BLOCKCHAIN_PAYMENT_PROTOCOL: "blockchain.payment.protocol";
324
340
  export declare const ATTR_BLOCKCHAIN_PAYMENT_PAYER: "blockchain.payment.payer";
@@ -328,6 +344,12 @@ export declare const ATTR_BLOCKCHAIN_PAYMENT_AMOUNT: "blockchain.payment.amount"
328
344
  export declare const ATTR_BLOCKCHAIN_PAYMENT_STATUS: "blockchain.payment.status";
329
345
  /** The amount the settling party reports it settled, e.g. less than the authorized maximum with x402 `upto`. */
330
346
  export declare const ATTR_BLOCKCHAIN_PAYMENT_SETTLED_AMOUNT: "blockchain.payment.settled_amount";
347
+ /**
348
+ * Whether the settlement transaction's receipt carries the payment, as checked by the adapter; absent when no check
349
+ * was possible. See
350
+ * https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0017-x402-payment-verification.md.
351
+ */
352
+ export declare const ATTR_BLOCKCHAIN_PAYMENT_VERIFIED: "blockchain.payment.verified";
331
353
  /** x402's own payment fields. */
332
354
  export declare const ATTR_X402_SCHEME: "x402.scheme";
333
355
  export declare const ATTR_X402_RESOURCE: "x402.resource";
@@ -349,7 +371,7 @@ export declare const BLOCKCHAIN_TX_STATUS_VALUE_REVERTED: "reverted";
349
371
  /**
350
372
  * @deprecated No longer recorded: a confirm span that gave up waiting records `error.type` `timeout` and no
351
373
  * `blockchain.tx.status`. The constant is removed in 1.0. See
352
- * https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0016-timeout-is-an-observer-outcome.md.
374
+ * https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0016-timeout-is-an-observer-outcome.md.
353
375
  */
354
376
  export declare const BLOCKCHAIN_TX_STATUS_VALUE_TIMEOUT: "timeout";
355
377
  export declare const BLOCKCHAIN_TX_STATUS_VALUE_REPLACED: "replaced";
@@ -362,11 +384,19 @@ export declare const ATTR_ERROR_TYPE: "error.type";
362
384
  /** Fallback {@link ATTR_ERROR_TYPE} value when the error has no name. */
363
385
  export declare const ERROR_TYPE_VALUE_OTHER: "_OTHER";
364
386
  //#endregion
387
+ //#region src/metrics.d.ts
388
+ /** Duration of a send: from the start of the sending call until the hash is known or the call failed. */
389
+ export declare const METRIC_BLOCKCHAIN_CLIENT_SEND_DURATION: "blockchain.client.send.duration";
390
+ /** Duration of a confirmation: from the start of the wait until the receipt, a timeout or a failure. */
391
+ export declare const METRIC_BLOCKCHAIN_CLIENT_CONFIRMATION_DURATION: "blockchain.client.confirmation.duration";
392
+ /** Total fee of a mined transaction (execution fee plus L1 data fee), in the chain's smallest unit (wei). */
393
+ export declare const METRIC_BLOCKCHAIN_CLIENT_FEE: "blockchain.client.fee";
394
+ //#endregion
365
395
  //#region src/tracker.d.ts
366
396
  /**
367
397
  * Records transactions and payments as spans. Obtain one from {@link createTxTracker}: it is not meant to be
368
398
  * implemented, and members may be added to it and to its handles in minor releases
369
- * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0014-core-api-boundary.md).
399
+ * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0014-core-api-boundary.md).
370
400
  */
371
401
  interface TxTracker {
372
402
  /**
@@ -384,7 +414,7 @@ interface TxTracker {
384
414
  /**
385
415
  * Starts a `payment` span as a child of `parent` (default: the active context), for a payment that another party
386
416
  * settles on chain
387
- * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0013-x402-payments.md). Call
417
+ * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0013-x402-payments.md). Call
388
418
  * `end(settlement)` with the settlement, or `fail(error)`. A settlement with a hash links the transaction's confirm
389
419
  * span to this span, as a send span would.
390
420
  */
@@ -392,7 +422,7 @@ interface TxTracker {
392
422
  }
393
423
  /**
394
424
  * Creates a tracker that records transactions as `send` and `confirm` spans, and payments as `payment` spans, with
395
- * `@opentelemetry/api` (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/semconv.md). It makes
425
+ * `@opentelemetry/api` (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/semconv.md). It makes
396
426
  * no network calls; the caller passes hashes and receipts. Its methods and handles never throw: failures are logged
397
427
  * via `diag`, and a method that fails returns a handle that records nothing.
398
428
  */
package/dist/index.mjs CHANGED
@@ -1,11 +1,11 @@
1
- import { SpanKind, SpanStatusCode, context, diag, propagation, trace } from "@opentelemetry/api";
1
+ import { SpanKind, SpanStatusCode, context, diag, metrics, propagation, trace } from "@opentelemetry/api";
2
2
  //#region src/agent.ts
3
3
  const ATTR_GEN_AI_AGENT_ID = "gen_ai.agent.id";
4
4
  const ATTR_GEN_AI_AGENT_NAME = "gen_ai.agent.name";
5
5
  /**
6
6
  * Agent identity as GenAI attributes. A field set in the static identity always wins; Baggage, which a remote caller
7
7
  * can set, only fills fields it leaves unset, and is not read at all with `fromBaggage` false
8
- * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0011-agent-identity-precedence.md).
8
+ * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0011-agent-identity-precedence.md).
9
9
  */
10
10
  function agentAttributes(ctx, identity, fromBaggage = true) {
11
11
  const baggage = fromBaggage ? propagation.getBaggage(ctx) : void 0;
@@ -21,7 +21,7 @@ function agentAttributes(ctx, identity, fromBaggage = true) {
21
21
  /**
22
22
  * Attribute keys emitted by hashspan.
23
23
  *
24
- * Stability: development. See https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/semconv.md for
24
+ * Stability: development. See https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/semconv.md for
25
25
  * definitions and value types. These names are a public contract: changes follow the deprecation policy in AGENTS.md.
26
26
  */
27
27
  const ATTR_BLOCKCHAIN_SYSTEM = "blockchain.system";
@@ -45,12 +45,12 @@ const ATTR_BLOCKCHAIN_CONTRACT_FUNCTION_NAME = "blockchain.contract.function.nam
45
45
  const ATTR_BLOCKCHAIN_CONTRACT_FUNCTION_SELECTOR = "blockchain.contract.function.selector";
46
46
  /**
47
47
  * Opt-in: decoded call arguments as a JSON array. See
48
- * https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0004-privacy-defaults.md.
48
+ * https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0004-privacy-defaults.md.
49
49
  */
50
50
  const ATTR_BLOCKCHAIN_CONTRACT_FUNCTION_ARGUMENTS = "blockchain.contract.function.arguments";
51
51
  /**
52
52
  * Payments settled on chain by a party other than the agent, e.g. an x402 facilitator. See
53
- * https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0013-x402-payments.md.
53
+ * https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0013-x402-payments.md.
54
54
  */
55
55
  const ATTR_BLOCKCHAIN_PAYMENT_PROTOCOL = "blockchain.payment.protocol";
56
56
  const ATTR_BLOCKCHAIN_PAYMENT_PAYER = "blockchain.payment.payer";
@@ -60,6 +60,12 @@ const ATTR_BLOCKCHAIN_PAYMENT_AMOUNT = "blockchain.payment.amount";
60
60
  const ATTR_BLOCKCHAIN_PAYMENT_STATUS = "blockchain.payment.status";
61
61
  /** The amount the settling party reports it settled, e.g. less than the authorized maximum with x402 `upto`. */
62
62
  const ATTR_BLOCKCHAIN_PAYMENT_SETTLED_AMOUNT = "blockchain.payment.settled_amount";
63
+ /**
64
+ * Whether the settlement transaction's receipt carries the payment, as checked by the adapter; absent when no check
65
+ * was possible. See
66
+ * https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0017-x402-payment-verification.md.
67
+ */
68
+ const ATTR_BLOCKCHAIN_PAYMENT_VERIFIED = "blockchain.payment.verified";
63
69
  /** x402's own payment fields. */
64
70
  const ATTR_X402_SCHEME = "x402.scheme";
65
71
  const ATTR_X402_RESOURCE = "x402.resource";
@@ -81,7 +87,7 @@ const BLOCKCHAIN_TX_STATUS_VALUE_REVERTED = "reverted";
81
87
  /**
82
88
  * @deprecated No longer recorded: a confirm span that gave up waiting records `error.type` `timeout` and no
83
89
  * `blockchain.tx.status`. The constant is removed in 1.0. See
84
- * https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0016-timeout-is-an-observer-outcome.md.
90
+ * https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0016-timeout-is-an-observer-outcome.md.
85
91
  */
86
92
  const BLOCKCHAIN_TX_STATUS_VALUE_TIMEOUT = "timeout";
87
93
  const BLOCKCHAIN_TX_STATUS_VALUE_REPLACED = "replaced";
@@ -94,6 +100,83 @@ const ATTR_ERROR_TYPE = "error.type";
94
100
  /** Fallback {@link ATTR_ERROR_TYPE} value when the error has no name. */
95
101
  const ERROR_TYPE_VALUE_OTHER = "_OTHER";
96
102
  //#endregion
103
+ //#region src/metrics.ts
104
+ /** Duration of a send: from the start of the sending call until the hash is known or the call failed. */
105
+ const METRIC_BLOCKCHAIN_CLIENT_SEND_DURATION = "blockchain.client.send.duration";
106
+ /** Duration of a confirmation: from the start of the wait until the receipt, a timeout or a failure. */
107
+ const METRIC_BLOCKCHAIN_CLIENT_CONFIRMATION_DURATION = "blockchain.client.confirmation.duration";
108
+ /** Total fee of a mined transaction (execution fee plus L1 data fee), in the chain's smallest unit (wei). */
109
+ const METRIC_BLOCKCHAIN_CLIENT_FEE = "blockchain.client.fee";
110
+ const DURATION_BUCKETS = [
111
+ .05,
112
+ .1,
113
+ .25,
114
+ .5,
115
+ 1,
116
+ 2,
117
+ 5,
118
+ 10,
119
+ 20,
120
+ 30,
121
+ 60,
122
+ 120,
123
+ 300
124
+ ];
125
+ const FEE_BUCKETS = Array.from({ length: 11 }, (_, i) => 10 ** (i + 8));
126
+ /** Milliseconds since the epoch of a span time, as the OpenTelemetry API accepts it. */
127
+ function toEpochMs(time) {
128
+ if (time === void 0) return Date.now();
129
+ if (time instanceof Date) return time.getTime();
130
+ if (Array.isArray(time)) return time[0] * 1e3 + time[1] / 1e6;
131
+ if (typeof time !== "number") return Date.now();
132
+ const origin = globalThis.performance?.timeOrigin;
133
+ return typeof origin === "number" && time < origin ? origin + time : time;
134
+ }
135
+ function createTxMetrics(meterProvider, name, version) {
136
+ let histograms;
137
+ const get = () => {
138
+ histograms ??= (() => {
139
+ const meter = (meterProvider ?? metrics.getMeterProvider()).getMeter(name, version);
140
+ return {
141
+ send: meter.createHistogram(METRIC_BLOCKCHAIN_CLIENT_SEND_DURATION, {
142
+ unit: "s",
143
+ description: "Duration of sending a transaction, until its hash is known",
144
+ advice: { explicitBucketBoundaries: DURATION_BUCKETS }
145
+ }),
146
+ confirmation: meter.createHistogram(METRIC_BLOCKCHAIN_CLIENT_CONFIRMATION_DURATION, {
147
+ unit: "s",
148
+ description: "Duration of waiting for a transaction receipt",
149
+ advice: { explicitBucketBoundaries: DURATION_BUCKETS }
150
+ }),
151
+ fee: meter.createHistogram(METRIC_BLOCKCHAIN_CLIENT_FEE, {
152
+ unit: "{wei}",
153
+ description: "Total fee of a mined transaction",
154
+ advice: { explicitBucketBoundaries: FEE_BUCKETS }
155
+ })
156
+ };
157
+ })();
158
+ return histograms;
159
+ };
160
+ const record = (what, run) => {
161
+ try {
162
+ run();
163
+ } catch (error) {
164
+ diag.error(`hashspan: failed to record the ${what} metric`, error);
165
+ }
166
+ };
167
+ return {
168
+ sendDuration: (seconds, attributes) => record("send duration", () => {
169
+ if (seconds >= 0) get().send.record(seconds, attributes);
170
+ }),
171
+ confirmationDuration: (seconds, attributes) => record("confirmation duration", () => {
172
+ if (seconds >= 0) get().confirmation.record(seconds, attributes);
173
+ }),
174
+ fee: (wei, attributes) => record("fee", () => {
175
+ if (wei >= 0n) get().fee.record(Number(wei), attributes);
176
+ })
177
+ };
178
+ }
179
+ //#endregion
97
180
  //#region src/confirm-registry.ts
98
181
  /**
99
182
  * Bounded registry from (chainId, tx hash) to its in-flight confirm span, or to "settled" for a while after a
@@ -379,7 +462,7 @@ function sanitizeResource(resource) {
379
462
  }
380
463
  //#endregion
381
464
  //#region src/version.ts
382
- const VERSION = "0.5.0";
465
+ const VERSION = "0.7.0";
383
466
  //#endregion
384
467
  //#region src/tracker.ts
385
468
  const INSTRUMENTATION_NAME = "@hashspan/core";
@@ -401,6 +484,7 @@ const NON_SENSITIVE_KEYS = /* @__PURE__ */ new Set([
401
484
  ATTR_BLOCKCHAIN_TX_REPLACEMENT_REASON,
402
485
  ATTR_BLOCKCHAIN_PAYMENT_PROTOCOL,
403
486
  ATTR_BLOCKCHAIN_PAYMENT_STATUS,
487
+ ATTR_BLOCKCHAIN_PAYMENT_VERIFIED,
404
488
  ATTR_ERROR_TYPE,
405
489
  ATTR_EXCEPTION_TYPE
406
490
  ]);
@@ -429,7 +513,8 @@ const noopSend = (parent) => ({
429
513
  const NOOP_PAYMENT = {
430
514
  end: () => {},
431
515
  fail: () => {},
432
- timeout: () => {}
516
+ timeout: () => {},
517
+ link: () => {}
433
518
  };
434
519
  const NOOP_CONFIRM = {
435
520
  end: () => {},
@@ -504,7 +589,7 @@ function reportedErrorType(error, options) {
504
589
  }
505
590
  /**
506
591
  * Creates a tracker that records transactions as `send` and `confirm` spans, and payments as `payment` spans, with
507
- * `@opentelemetry/api` (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/semconv.md). It makes
592
+ * `@opentelemetry/api` (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/semconv.md). It makes
508
593
  * no network calls; the caller passes hashes and receipts. Its methods and handles never throw: failures are logged
509
594
  * via `diag`, and a method that fails returns a handle that records nothing.
510
595
  */
@@ -520,6 +605,14 @@ function createTxTracker(options = {}) {
520
605
  const formatAddress = safely("configure address mode", () => resolveAddressFormatter(options.address), OFF_ADDRESS_FORMATTER);
521
606
  const errorMessages = safely("configure error message mode", () => resolveErrorMessageMode(options.errorMessages), "off");
522
607
  const paymentResource = safely("configure payment resource mode", () => resolvePaymentResourceMode(options.paymentResource), "off");
608
+ const txMetrics = createTxMetrics(options.meterProvider, INSTRUMENTATION_NAME, VERSION);
609
+ /** Attributes of a metric: low-cardinality only, never an address, hash or agent identity. */
610
+ const metricAttributes = (chainId, extra = {}) => ({
611
+ [ATTR_BLOCKCHAIN_SYSTEM]: "evm",
612
+ [ATTR_BLOCKCHAIN_CHAIN_ID]: chainId,
613
+ ...extra
614
+ });
615
+ const secondsSince = (startMs, endTime) => (toEpochMs(endTime) - startMs) / 1e3;
523
616
  let tracer;
524
617
  const getTracer = () => {
525
618
  tracer ??= (options.tracerProvider ?? trace.getTracerProvider()).getTracer(INSTRUMENTATION_NAME, VERSION);
@@ -544,7 +637,7 @@ function createTxTracker(options = {}) {
544
637
  /**
545
638
  * Exception event attributes for `error`, per the error message mode. The error object itself is never handed to
546
639
  * the SDK: its message and stack can carry addresses and calldata
547
- * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0006-error-privacy.md).
640
+ * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0006-error-privacy.md).
548
641
  */
549
642
  const exceptionAttributes = (type, error) => {
550
643
  const attributes = { [ATTR_EXCEPTION_TYPE]: type };
@@ -577,6 +670,7 @@ function createTxTracker(options = {}) {
577
670
  code: SpanStatusCode.ERROR,
578
671
  ...message !== void 0 ? { message } : {}
579
672
  });
673
+ return type;
580
674
  };
581
675
  /** Ends a span exactly once; the span is always ended even if recording attributes fails. */
582
676
  const finisher = (span) => {
@@ -628,6 +722,8 @@ function createTxTracker(options = {}) {
628
722
  ...input.startTime !== void 0 ? { startTime: input.startTime } : {}
629
723
  }, parent);
630
724
  const finish = finisher(span);
725
+ const startMs = toEpochMs(input.startTime);
726
+ const recordSend = (endTime, errorType) => txMetrics.sendDuration(secondsSince(startMs, endTime), metricAttributes(input.chainId, errorType === void 0 ? {} : { [ATTR_ERROR_TYPE]: errorType }));
631
727
  return {
632
728
  context: trace.setSpan(parent, span),
633
729
  end: (result, second) => finish("record transaction hash", () => {
@@ -641,10 +737,11 @@ function createTxTracker(options = {}) {
641
737
  parent
642
738
  });
643
739
  span.setAttributes(redact({ [ATTR_BLOCKCHAIN_TX_HASH]: hash }));
740
+ recordSend(handleOptions(second).endTime);
644
741
  }, handleOptions(second).endTime),
645
742
  fail: (error, second, third) => {
646
743
  const options = handleOptions(second, third);
647
- finish("record send failure", () => markError(span, reportedErrorType(error, options), error, errorType(error)), options.endTime);
744
+ finish("record send failure", () => recordSend(options.endTime, markError(span, reportedErrorType(error, options), error, errorType(error))), options.endTime);
648
745
  }
649
746
  };
650
747
  };
@@ -680,6 +777,8 @@ function createTxTracker(options = {}) {
680
777
  ...explicitStart !== void 0 ? { startTime: explicitStart } : {}
681
778
  }, parent);
682
779
  const finish = finisher(span);
780
+ const startMs = toEpochMs(explicitStart);
781
+ const recordConfirmation = (endTime, outcome) => txMetrics.confirmationDuration(secondsSince(startMs, endTime), metricAttributes(input.chainId, outcome));
683
782
  return {
684
783
  active: 0,
685
784
  ended: false,
@@ -689,11 +788,16 @@ function createTxTracker(options = {}) {
689
788
  links: [{ context: span.spanContext() }, ...sent ? [{ context: sent.spanContext }] : []]
690
789
  },
691
790
  receipt: (receipt, endTime) => finish("record receipt", () => {
692
- span.setAttributes(redact(receiptAttributes(receipt)));
791
+ const attributes = receiptAttributes(receipt);
792
+ span.setAttributes(redact(attributes));
693
793
  if (receipt.status === "reverted") markError(span, BLOCKCHAIN_TX_STATUS_VALUE_REVERTED);
794
+ const status = { [ATTR_BLOCKCHAIN_TX_STATUS]: attributes[ATTR_BLOCKCHAIN_TX_STATUS] };
795
+ recordConfirmation(endTime, status);
796
+ const fee = attributes[ATTR_BLOCKCHAIN_TX_FEE];
797
+ if (typeof fee === "string") txMetrics.fee(BigInt(fee), metricAttributes(input.chainId, status));
694
798
  }, endTime),
695
- timeout: (endTime) => finish("record confirmation timeout", () => markError(span, OBSERVER_TIMEOUT), endTime),
696
- fail: (error, endTime) => finish("record confirmation failure", () => markError(span, errorType(error), error), endTime),
799
+ timeout: (endTime) => finish("record confirmation timeout", () => recordConfirmation(endTime, { [ATTR_ERROR_TYPE]: markError(span, OBSERVER_TIMEOUT) }), endTime),
800
+ fail: (error, endTime) => finish("record confirmation failure", () => recordConfirmation(endTime, { [ATTR_ERROR_TYPE]: markError(span, errorType(error), error) }), endTime),
697
801
  replaced: (hash, reason, endTime) => finish("record replacement", () => {
698
802
  const attributes = {
699
803
  [ATTR_BLOCKCHAIN_TX_STATUS]: BLOCKCHAIN_TX_STATUS_VALUE_REPLACED,
@@ -701,8 +805,9 @@ function createTxTracker(options = {}) {
701
805
  };
702
806
  if (reason !== void 0 && REPLACEMENT_REASONS.has(reason)) attributes[ATTR_BLOCKCHAIN_TX_REPLACEMENT_REASON] = reason;
703
807
  span.setAttributes(redact(attributes));
808
+ recordConfirmation(endTime, { [ATTR_BLOCKCHAIN_TX_STATUS]: BLOCKCHAIN_TX_STATUS_VALUE_REPLACED });
704
809
  }, endTime),
705
- unattributable: (endTime) => finish("record unattributable receipt", () => markError(span, ERROR_TYPE_VALUE_OTHER), endTime)
810
+ unattributable: (endTime) => finish("record unattributable receipt", () => recordConfirmation(endTime, { [ATTR_ERROR_TYPE]: markError(span, ERROR_TYPE_VALUE_OTHER) }), endTime)
706
811
  };
707
812
  };
708
813
  /**
@@ -724,7 +829,7 @@ function createTxTracker(options = {}) {
724
829
  };
725
830
  /**
726
831
  * Ends `shared` with `receipt`, attributing it to the transaction that was mined
727
- * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.5.0/docs/adr/0008-replaced-transactions.md).
832
+ * (https://github.com/selimaytac/hashspan/blob/@hashspan/core@0.7.0/docs/adr/0008-replaced-transactions.md).
728
833
  */
729
834
  const endWithReceipt = (chainId, hash, shared, receipt, endTime) => {
730
835
  const mined = receipt.transactionHash;
@@ -814,6 +919,15 @@ function createTxTracker(options = {}) {
814
919
  ...input.startTime !== void 0 ? { startTime: input.startTime } : {}
815
920
  }, parent);
816
921
  const finish = finisher(span);
922
+ /** Links the confirm span of `hash` to this payment span, unless the tracker already links that hash. */
923
+ const linkHash = (hash) => {
924
+ if (typeof hash !== "string" || !TX_HASH.test(hash)) return false;
925
+ if (!links.get(input.chainId, hash)) links.set(input.chainId, hash, {
926
+ spanContext: span.spanContext(),
927
+ parent
928
+ });
929
+ return true;
930
+ };
817
931
  const recordSettlement = (settlement) => {
818
932
  const status = settlement.status;
819
933
  if (!PAYMENT_STATUSES.has(status)) {
@@ -822,19 +936,15 @@ function createTxTracker(options = {}) {
822
936
  }
823
937
  const settled = { [ATTR_BLOCKCHAIN_PAYMENT_STATUS]: status };
824
938
  const hash = settlement.hash;
825
- if (typeof hash === "string" && TX_HASH.test(hash)) {
826
- if (!links.get(input.chainId, hash)) links.set(input.chainId, hash, {
827
- spanContext: span.spanContext(),
828
- parent
829
- });
830
- settled[ATTR_BLOCKCHAIN_TX_HASH] = hash;
831
- }
939
+ if (linkHash(hash)) settled[ATTR_BLOCKCHAIN_TX_HASH] = hash;
832
940
  if (!knownPayer) setPaymentAddress(settled, ATTR_BLOCKCHAIN_PAYMENT_PAYER, settlement.payer);
833
941
  const settledAmount = amount(settlement.amount);
834
942
  if (settledAmount !== void 0) {
835
943
  settled[ATTR_BLOCKCHAIN_PAYMENT_SETTLED_AMOUNT] = settledAmount;
836
944
  if (paid === void 0) settled[ATTR_BLOCKCHAIN_PAYMENT_AMOUNT] = settledAmount;
837
945
  }
946
+ const verified = settlement.verified;
947
+ if (typeof verified === "boolean") settled[ATTR_BLOCKCHAIN_PAYMENT_VERIFIED] = verified;
838
948
  span.setAttributes(redact(settled));
839
949
  if (status === "failed") markError(span, identifier(settlement.errorReason) ?? "_OTHER");
840
950
  };
@@ -844,7 +954,8 @@ function createTxTracker(options = {}) {
844
954
  const read = handleOptions(options);
845
955
  finish("record payment failure", () => markError(span, reportedErrorType(error, read), error, errorType(error)), read.endTime);
846
956
  },
847
- timeout: (options) => finish("record payment timeout", () => markError(span, OBSERVER_TIMEOUT), handleOptions(options).endTime)
957
+ timeout: (options) => finish("record payment timeout", () => markError(span, OBSERVER_TIMEOUT), handleOptions(options).endTime),
958
+ link: (hash) => safely("link the payment span", () => void linkHash(hash), void 0)
848
959
  };
849
960
  };
850
961
  return {
@@ -854,4 +965,4 @@ function createTxTracker(options = {}) {
854
965
  };
855
966
  }
856
967
  //#endregion
857
- 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_PAYMENT_AMOUNT, ATTR_BLOCKCHAIN_PAYMENT_ASSET, ATTR_BLOCKCHAIN_PAYMENT_PAYER, ATTR_BLOCKCHAIN_PAYMENT_PROTOCOL, ATTR_BLOCKCHAIN_PAYMENT_RECIPIENT, ATTR_BLOCKCHAIN_PAYMENT_SETTLED_AMOUNT, ATTR_BLOCKCHAIN_PAYMENT_STATUS, 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, ATTR_X402_RESOURCE, ATTR_X402_SCHEME, BLOCKCHAIN_OPERATION_NAME_VALUE_CONFIRM, BLOCKCHAIN_OPERATION_NAME_VALUE_PAYMENT, BLOCKCHAIN_OPERATION_NAME_VALUE_SEND, BLOCKCHAIN_PAYMENT_PROTOCOL_VALUE_X402, BLOCKCHAIN_PAYMENT_STATUS_VALUE_FAILED, BLOCKCHAIN_PAYMENT_STATUS_VALUE_PENDING, BLOCKCHAIN_PAYMENT_STATUS_VALUE_SETTLED, 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 };
968
+ 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_PAYMENT_AMOUNT, ATTR_BLOCKCHAIN_PAYMENT_ASSET, ATTR_BLOCKCHAIN_PAYMENT_PAYER, ATTR_BLOCKCHAIN_PAYMENT_PROTOCOL, ATTR_BLOCKCHAIN_PAYMENT_RECIPIENT, ATTR_BLOCKCHAIN_PAYMENT_SETTLED_AMOUNT, ATTR_BLOCKCHAIN_PAYMENT_STATUS, ATTR_BLOCKCHAIN_PAYMENT_VERIFIED, 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, ATTR_X402_RESOURCE, ATTR_X402_SCHEME, BLOCKCHAIN_OPERATION_NAME_VALUE_CONFIRM, BLOCKCHAIN_OPERATION_NAME_VALUE_PAYMENT, BLOCKCHAIN_OPERATION_NAME_VALUE_SEND, BLOCKCHAIN_PAYMENT_PROTOCOL_VALUE_X402, BLOCKCHAIN_PAYMENT_STATUS_VALUE_FAILED, BLOCKCHAIN_PAYMENT_STATUS_VALUE_PENDING, BLOCKCHAIN_PAYMENT_STATUS_VALUE_SETTLED, 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, METRIC_BLOCKCHAIN_CLIENT_CONFIRMATION_DURATION, METRIC_BLOCKCHAIN_CLIENT_FEE, METRIC_BLOCKCHAIN_CLIENT_SEND_DURATION, VERSION, createTxTracker };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hashspan/core",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Transaction lifecycle tracing for on-chain actions of AI agents, built on OpenTelemetry.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Selim Aytac",