@fedify/vocab 2.3.0-dev.1131 → 2.3.0-dev.1145

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/deno.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fedify/vocab",
3
- "version": "2.3.0-dev.1131+553b59b8",
3
+ "version": "2.3.0-dev.1145+3fe78022",
4
4
  "license": "MIT",
5
5
  "exports": {
6
6
  ".": "./src/mod.ts"
package/dist/mod.cjs CHANGED
@@ -33,7 +33,7 @@ let _fedify_vocab_runtime_temporal = require("@fedify/vocab-runtime/temporal");
33
33
  let es_toolkit = require("es-toolkit");
34
34
  //#region deno.json
35
35
  var name = "@fedify/vocab";
36
- var version = "2.3.0-dev.1131+553b59b8";
36
+ var version = "2.3.0-dev.1145+3fe78022";
37
37
  //#endregion
38
38
  //#region src/type.ts
39
39
  function getTypeId(object) {
@@ -47333,6 +47333,53 @@ function getEntityTypeById(id) {
47333
47333
  }
47334
47334
  //#endregion
47335
47335
  //#region src/actor.ts
47336
+ const ACTOR_DISCOVERY_HISTOGRAM_BUCKETS = [
47337
+ 5,
47338
+ 10,
47339
+ 25,
47340
+ 50,
47341
+ 75,
47342
+ 100,
47343
+ 250,
47344
+ 500,
47345
+ 750,
47346
+ 1e3,
47347
+ 2500,
47348
+ 5e3,
47349
+ 7500,
47350
+ 1e4
47351
+ ];
47352
+ const actorDiscoveryInstruments = /* @__PURE__ */ new WeakMap();
47353
+ function getActorDiscoveryInstruments(meterProvider) {
47354
+ let instruments = actorDiscoveryInstruments.get(meterProvider);
47355
+ if (instruments == null) {
47356
+ const meter = meterProvider.getMeter(name, version);
47357
+ instruments = {
47358
+ discovery: meter.createCounter("activitypub.actor.discovery", {
47359
+ description: "Actor handle discovery attempts via getActorHandle(), classified by terminal outcome.",
47360
+ unit: "{discovery}"
47361
+ }),
47362
+ discoveryDuration: meter.createHistogram("activitypub.actor.discovery.duration", {
47363
+ description: "Duration of getActorHandle() actor discovery calls.",
47364
+ unit: "ms",
47365
+ advice: { explicitBucketBoundaries: [...ACTOR_DISCOVERY_HISTOGRAM_BUCKETS] }
47366
+ })
47367
+ };
47368
+ actorDiscoveryInstruments.set(meterProvider, instruments);
47369
+ }
47370
+ return instruments;
47371
+ }
47372
+ function getActorDiscoveryRemoteHost(actor) {
47373
+ const id = actor instanceof URL ? actor : actor.id;
47374
+ if (id == null) return void 0;
47375
+ return id.hostname === "" ? void 0 : id.hostname;
47376
+ }
47377
+ var ActorHandleNotFoundError = class extends TypeError {
47378
+ constructor() {
47379
+ super("Actor does not have enough information to get the handle.");
47380
+ this.name = "ActorHandleNotFoundError";
47381
+ }
47382
+ };
47336
47383
  /**
47337
47384
  * Checks if the given object is an {@link Actor}.
47338
47385
  * @param object The object to check.
@@ -47399,15 +47446,29 @@ async function getActorHandle(actor, options = {}) {
47399
47446
  if (actor.id != null) span.setAttribute("activitypub.actor.id", actor.id.href);
47400
47447
  span.setAttribute("activitypub.actor.type", getTypeId(actor).href);
47401
47448
  }
47449
+ const meterProvider = options.meterProvider;
47450
+ const start = meterProvider == null ? 0 : performance.now();
47451
+ let result = "error";
47402
47452
  try {
47403
- return await getActorHandleInternal(actor, options);
47453
+ const handle = await getActorHandleInternal(actor, options);
47454
+ result = "resolved";
47455
+ return handle;
47404
47456
  } catch (error) {
47457
+ result = error instanceof ActorHandleNotFoundError ? "not_found" : "error";
47405
47458
  span.setStatus({
47406
47459
  code: _opentelemetry_api.SpanStatusCode.ERROR,
47407
47460
  message: String(error)
47408
47461
  });
47409
47462
  throw error;
47410
47463
  } finally {
47464
+ if (meterProvider != null) {
47465
+ const attributes = { "activitypub.actor.discovery.result": result };
47466
+ const host = getActorDiscoveryRemoteHost(actor);
47467
+ if (host != null) attributes["activitypub.remote.host"] = host;
47468
+ const instruments = getActorDiscoveryInstruments(meterProvider);
47469
+ instruments.discovery.add(1, attributes);
47470
+ instruments.discoveryDuration.record(Math.max(0, performance.now() - start), attributes);
47471
+ }
47411
47472
  span.end();
47412
47473
  }
47413
47474
  });
@@ -47417,7 +47478,8 @@ async function getActorHandleInternal(actor, options = {}) {
47417
47478
  if (actorId != null) {
47418
47479
  const result = await (0, _fedify_webfinger.lookupWebFinger)(actorId, {
47419
47480
  userAgent: options.userAgent,
47420
- tracerProvider: options.tracerProvider
47481
+ tracerProvider: options.tracerProvider,
47482
+ meterProvider: options.meterProvider
47421
47483
  });
47422
47484
  if (result != null) {
47423
47485
  const aliases = [...result.aliases ?? []];
@@ -47425,19 +47487,20 @@ async function getActorHandleInternal(actor, options = {}) {
47425
47487
  for (const alias of aliases) {
47426
47488
  const match = alias.match(/^acct:([^@]+)@([^@]+)$/);
47427
47489
  if (match != null) {
47428
- if (new URL(`https://${match[2]}/`).hostname !== actorId.hostname && !await verifyCrossOriginActorHandle(actorId.href, alias, options.userAgent, options.tracerProvider)) continue;
47490
+ if (new URL(`https://${match[2]}/`).hostname !== actorId.hostname && !await verifyCrossOriginActorHandle(actorId.href, alias, options.userAgent, options.tracerProvider, options.meterProvider)) continue;
47429
47491
  return normalizeActorHandle(`@${match[1]}@${match[2]}`, options);
47430
47492
  }
47431
47493
  }
47432
47494
  }
47433
47495
  }
47434
47496
  if (!(actor instanceof URL) && actor.preferredUsername != null && actor.id != null) return normalizeActorHandle(`@${actor.preferredUsername}@${actor.id.host}`, options);
47435
- throw new TypeError("Actor does not have enough information to get the handle.");
47497
+ throw new ActorHandleNotFoundError();
47436
47498
  }
47437
- async function verifyCrossOriginActorHandle(actorId, alias, userAgent, tracerProvider) {
47499
+ async function verifyCrossOriginActorHandle(actorId, alias, userAgent, tracerProvider, meterProvider) {
47438
47500
  const response = await (0, _fedify_webfinger.lookupWebFinger)(alias, {
47439
47501
  userAgent,
47440
- tracerProvider
47502
+ tracerProvider,
47503
+ meterProvider
47441
47504
  });
47442
47505
  if (response == null) return false;
47443
47506
  for (const alias of response.aliases ?? []) if (new URL(alias).href === actorId) return true;
@@ -47671,6 +47734,7 @@ async function lookupObjectInternal(identifier, options = {}) {
47671
47734
  const jrd = await (0, _fedify_webfinger.lookupWebFinger)(identifier, {
47672
47735
  userAgent: options.userAgent,
47673
47736
  tracerProvider: options.tracerProvider,
47737
+ meterProvider: options.meterProvider,
47674
47738
  allowPrivateAddress: "allowPrivateAddress" in options && options.allowPrivateAddress === true,
47675
47739
  signal: options.signal
47676
47740
  });
package/dist/mod.d.cts CHANGED
@@ -18088,6 +18088,24 @@ declare function getEntityTypeById(id: string | URL): $EntityType | undefined;
18088
18088
  //#endregion
18089
18089
  //#region src/actor.d.ts
18090
18090
  /**
18091
+ * The terminal classification of a {@link getActorHandle} call, recorded as
18092
+ * the `activitypub.actor.discovery.result` attribute on the
18093
+ * `activitypub.actor.discovery` counter and
18094
+ * `activitypub.actor.discovery.duration` histogram.
18095
+ *
18096
+ * - `resolved`: a handle was returned to the caller.
18097
+ * - `not_found`: WebFinger did not yield a usable `acct:` alias and the
18098
+ * `preferredUsername` fallback could not run. This corresponds to the
18099
+ * `TypeError("Actor does not have enough information…")` path.
18100
+ * - `error`: any other thrown exception bubbled up from the lookup.
18101
+ *
18102
+ * Per-WebFinger-call failure detail (HTTP status, parse failure, network
18103
+ * failure, etc.) lives on `webfinger.lookup` and is intentionally not
18104
+ * duplicated here.
18105
+ * @since 2.3.0
18106
+ */
18107
+ type ActorDiscoveryResult = "resolved" | "not_found" | "error";
18108
+ /**
18091
18109
  * Actor types are {@link Object} types that are capable of performing
18092
18110
  * activities.
18093
18111
  */
@@ -18133,6 +18151,18 @@ interface GetActorHandleOptions extends NormalizeActorHandleOptions {
18133
18151
  * @since 1.3.0
18134
18152
  */
18135
18153
  tracerProvider?: TracerProvider;
18154
+ /**
18155
+ * The OpenTelemetry meter provider used to record the
18156
+ * `activitypub.actor.discovery` counter and
18157
+ * `activitypub.actor.discovery.duration` histogram. When set, the same
18158
+ * meter provider is also forwarded to the nested WebFinger lookups so
18159
+ * each discovery emits both the actor-discovery measurements and the
18160
+ * underlying `webfinger.lookup` measurements. If omitted, no
18161
+ * measurements are emitted (the helper is opt-in to avoid touching the
18162
+ * global meter provider for callers that do not use OpenTelemetry).
18163
+ * @since 2.3.0
18164
+ */
18165
+ meterProvider?: MeterProvider;
18136
18166
  }
18137
18167
  /**
18138
18168
  * Gets the actor handle, of the form `@username@domain`, from the given actor
@@ -18531,4 +18561,4 @@ declare function getTypeId(object: Object$1 | Link | null): URL | null;
18531
18561
  */
18532
18562
  declare function getTypeId(object: Object$1 | Link | null | undefined): URL | null | undefined;
18533
18563
  //#endregion
18534
- export { $EntityType, Accept, Activity, Actor, ActorTypeName, Add, Announce, AnnounceAuthorization, AnnounceRequest, Application, Arrive, Article, Audio, Block, ChatMessage, Collection, CollectionPage, Create, CryptographicKey, DataIntegrityProof, Delete, DidService, Dislike, Document, type DocumentLoader, Emoji, EmojiReact, Endpoints, Event, Export, FediverseHandle, Flag, Follow, GetActorHandleOptions, type GetUserAgentOptions, Group, Hashtag, Ignore, Image, Intent, InteractionPolicy, InteractionRule, IntransitiveActivity, Invite, Join, LanguageString, Leave, Like, LikeAuthorization, LikeRequest, Link, Listen, LookupObjectOptions, Measure, Mention, Move, Multikey, NormalizeActorHandleOptions, Note, Object$1 as Object, ObjectLookupKind, Offer, OrderedCollection, OrderedCollectionPage, Organization, PUBLIC_COLLECTION, Page, Person, Place, Profile, PropertyValue, Proposal, Question, QuoteAuthorization, QuoteRequest, Read, Recipient, Reject, Relationship, type RemoteDocument, Remove, ReplyAuthorization, ReplyRequest, Service, Source, TentativeAccept, TentativeReject, Tombstone, Travel, TraverseCollectionOptions, Undo, Update, Video, View, getActorClassByTypeName, getActorHandle, getActorTypeName, getEntityTypeById, getTypeId, isActor, isEntityType, isFediverseHandle, lookupObject, normalizeActorHandle, parseFediverseHandle, toAcctUrl, traverseCollection };
18564
+ export { $EntityType, Accept, Activity, Actor, ActorDiscoveryResult, ActorTypeName, Add, Announce, AnnounceAuthorization, AnnounceRequest, Application, Arrive, Article, Audio, Block, ChatMessage, Collection, CollectionPage, Create, CryptographicKey, DataIntegrityProof, Delete, DidService, Dislike, Document, type DocumentLoader, Emoji, EmojiReact, Endpoints, Event, Export, FediverseHandle, Flag, Follow, GetActorHandleOptions, type GetUserAgentOptions, Group, Hashtag, Ignore, Image, Intent, InteractionPolicy, InteractionRule, IntransitiveActivity, Invite, Join, LanguageString, Leave, Like, LikeAuthorization, LikeRequest, Link, Listen, LookupObjectOptions, Measure, Mention, Move, Multikey, NormalizeActorHandleOptions, Note, Object$1 as Object, ObjectLookupKind, Offer, OrderedCollection, OrderedCollectionPage, Organization, PUBLIC_COLLECTION, Page, Person, Place, Profile, PropertyValue, Proposal, Question, QuoteAuthorization, QuoteRequest, Read, Recipient, Reject, Relationship, type RemoteDocument, Remove, ReplyAuthorization, ReplyRequest, Service, Source, TentativeAccept, TentativeReject, Tombstone, Travel, TraverseCollectionOptions, Undo, Update, Video, View, getActorClassByTypeName, getActorHandle, getActorTypeName, getEntityTypeById, getTypeId, isActor, isEntityType, isFediverseHandle, lookupObject, normalizeActorHandle, parseFediverseHandle, toAcctUrl, traverseCollection };
package/dist/mod.d.ts CHANGED
@@ -18088,6 +18088,24 @@ declare function getEntityTypeById(id: string | URL): $EntityType | undefined;
18088
18088
  //#endregion
18089
18089
  //#region src/actor.d.ts
18090
18090
  /**
18091
+ * The terminal classification of a {@link getActorHandle} call, recorded as
18092
+ * the `activitypub.actor.discovery.result` attribute on the
18093
+ * `activitypub.actor.discovery` counter and
18094
+ * `activitypub.actor.discovery.duration` histogram.
18095
+ *
18096
+ * - `resolved`: a handle was returned to the caller.
18097
+ * - `not_found`: WebFinger did not yield a usable `acct:` alias and the
18098
+ * `preferredUsername` fallback could not run. This corresponds to the
18099
+ * `TypeError("Actor does not have enough information…")` path.
18100
+ * - `error`: any other thrown exception bubbled up from the lookup.
18101
+ *
18102
+ * Per-WebFinger-call failure detail (HTTP status, parse failure, network
18103
+ * failure, etc.) lives on `webfinger.lookup` and is intentionally not
18104
+ * duplicated here.
18105
+ * @since 2.3.0
18106
+ */
18107
+ type ActorDiscoveryResult = "resolved" | "not_found" | "error";
18108
+ /**
18091
18109
  * Actor types are {@link Object} types that are capable of performing
18092
18110
  * activities.
18093
18111
  */
@@ -18133,6 +18151,18 @@ interface GetActorHandleOptions extends NormalizeActorHandleOptions {
18133
18151
  * @since 1.3.0
18134
18152
  */
18135
18153
  tracerProvider?: TracerProvider;
18154
+ /**
18155
+ * The OpenTelemetry meter provider used to record the
18156
+ * `activitypub.actor.discovery` counter and
18157
+ * `activitypub.actor.discovery.duration` histogram. When set, the same
18158
+ * meter provider is also forwarded to the nested WebFinger lookups so
18159
+ * each discovery emits both the actor-discovery measurements and the
18160
+ * underlying `webfinger.lookup` measurements. If omitted, no
18161
+ * measurements are emitted (the helper is opt-in to avoid touching the
18162
+ * global meter provider for callers that do not use OpenTelemetry).
18163
+ * @since 2.3.0
18164
+ */
18165
+ meterProvider?: MeterProvider;
18136
18166
  }
18137
18167
  /**
18138
18168
  * Gets the actor handle, of the form `@username@domain`, from the given actor
@@ -18531,4 +18561,4 @@ declare function getTypeId(object: Object$1 | Link | null): URL | null;
18531
18561
  */
18532
18562
  declare function getTypeId(object: Object$1 | Link | null | undefined): URL | null | undefined;
18533
18563
  //#endregion
18534
- export { $EntityType, Accept, Activity, Actor, ActorTypeName, Add, Announce, AnnounceAuthorization, AnnounceRequest, Application, Arrive, Article, Audio, Block, ChatMessage, Collection, CollectionPage, Create, CryptographicKey, DataIntegrityProof, Delete, DidService, Dislike, Document, type DocumentLoader, Emoji, EmojiReact, Endpoints, Event, Export, FediverseHandle, Flag, Follow, GetActorHandleOptions, type GetUserAgentOptions, Group, Hashtag, Ignore, Image, Intent, InteractionPolicy, InteractionRule, IntransitiveActivity, Invite, Join, LanguageString, Leave, Like, LikeAuthorization, LikeRequest, Link, Listen, LookupObjectOptions, Measure, Mention, Move, Multikey, NormalizeActorHandleOptions, Note, Object$1 as Object, ObjectLookupKind, Offer, OrderedCollection, OrderedCollectionPage, Organization, PUBLIC_COLLECTION, Page, Person, Place, Profile, PropertyValue, Proposal, Question, QuoteAuthorization, QuoteRequest, Read, Recipient, Reject, Relationship, type RemoteDocument, Remove, ReplyAuthorization, ReplyRequest, Service, Source, TentativeAccept, TentativeReject, Tombstone, Travel, TraverseCollectionOptions, Undo, Update, Video, View, getActorClassByTypeName, getActorHandle, getActorTypeName, getEntityTypeById, getTypeId, isActor, isEntityType, isFediverseHandle, lookupObject, normalizeActorHandle, parseFediverseHandle, toAcctUrl, traverseCollection };
18564
+ export { $EntityType, Accept, Activity, Actor, ActorDiscoveryResult, ActorTypeName, Add, Announce, AnnounceAuthorization, AnnounceRequest, Application, Arrive, Article, Audio, Block, ChatMessage, Collection, CollectionPage, Create, CryptographicKey, DataIntegrityProof, Delete, DidService, Dislike, Document, type DocumentLoader, Emoji, EmojiReact, Endpoints, Event, Export, FediverseHandle, Flag, Follow, GetActorHandleOptions, type GetUserAgentOptions, Group, Hashtag, Ignore, Image, Intent, InteractionPolicy, InteractionRule, IntransitiveActivity, Invite, Join, LanguageString, Leave, Like, LikeAuthorization, LikeRequest, Link, Listen, LookupObjectOptions, Measure, Mention, Move, Multikey, NormalizeActorHandleOptions, Note, Object$1 as Object, ObjectLookupKind, Offer, OrderedCollection, OrderedCollectionPage, Organization, PUBLIC_COLLECTION, Page, Person, Place, Profile, PropertyValue, Proposal, Question, QuoteAuthorization, QuoteRequest, Read, Recipient, Reject, Relationship, type RemoteDocument, Remove, ReplyAuthorization, ReplyRequest, Service, Source, TentativeAccept, TentativeReject, Tombstone, Travel, TraverseCollectionOptions, Undo, Update, Video, View, getActorClassByTypeName, getActorHandle, getActorTypeName, getEntityTypeById, getTypeId, isActor, isEntityType, isFediverseHandle, lookupObject, normalizeActorHandle, parseFediverseHandle, toAcctUrl, traverseCollection };
package/dist/mod.js CHANGED
@@ -9,7 +9,7 @@ import { isTemporalDuration, isTemporalInstant } from "@fedify/vocab-runtime/tem
9
9
  import { delay } from "es-toolkit";
10
10
  //#region deno.json
11
11
  var name = "@fedify/vocab";
12
- var version = "2.3.0-dev.1131+553b59b8";
12
+ var version = "2.3.0-dev.1145+3fe78022";
13
13
  //#endregion
14
14
  //#region src/type.ts
15
15
  function getTypeId(object) {
@@ -47309,6 +47309,53 @@ function getEntityTypeById(id) {
47309
47309
  }
47310
47310
  //#endregion
47311
47311
  //#region src/actor.ts
47312
+ const ACTOR_DISCOVERY_HISTOGRAM_BUCKETS = [
47313
+ 5,
47314
+ 10,
47315
+ 25,
47316
+ 50,
47317
+ 75,
47318
+ 100,
47319
+ 250,
47320
+ 500,
47321
+ 750,
47322
+ 1e3,
47323
+ 2500,
47324
+ 5e3,
47325
+ 7500,
47326
+ 1e4
47327
+ ];
47328
+ const actorDiscoveryInstruments = /* @__PURE__ */ new WeakMap();
47329
+ function getActorDiscoveryInstruments(meterProvider) {
47330
+ let instruments = actorDiscoveryInstruments.get(meterProvider);
47331
+ if (instruments == null) {
47332
+ const meter = meterProvider.getMeter(name, version);
47333
+ instruments = {
47334
+ discovery: meter.createCounter("activitypub.actor.discovery", {
47335
+ description: "Actor handle discovery attempts via getActorHandle(), classified by terminal outcome.",
47336
+ unit: "{discovery}"
47337
+ }),
47338
+ discoveryDuration: meter.createHistogram("activitypub.actor.discovery.duration", {
47339
+ description: "Duration of getActorHandle() actor discovery calls.",
47340
+ unit: "ms",
47341
+ advice: { explicitBucketBoundaries: [...ACTOR_DISCOVERY_HISTOGRAM_BUCKETS] }
47342
+ })
47343
+ };
47344
+ actorDiscoveryInstruments.set(meterProvider, instruments);
47345
+ }
47346
+ return instruments;
47347
+ }
47348
+ function getActorDiscoveryRemoteHost(actor) {
47349
+ const id = actor instanceof URL ? actor : actor.id;
47350
+ if (id == null) return void 0;
47351
+ return id.hostname === "" ? void 0 : id.hostname;
47352
+ }
47353
+ var ActorHandleNotFoundError = class extends TypeError {
47354
+ constructor() {
47355
+ super("Actor does not have enough information to get the handle.");
47356
+ this.name = "ActorHandleNotFoundError";
47357
+ }
47358
+ };
47312
47359
  /**
47313
47360
  * Checks if the given object is an {@link Actor}.
47314
47361
  * @param object The object to check.
@@ -47375,15 +47422,29 @@ async function getActorHandle(actor, options = {}) {
47375
47422
  if (actor.id != null) span.setAttribute("activitypub.actor.id", actor.id.href);
47376
47423
  span.setAttribute("activitypub.actor.type", getTypeId(actor).href);
47377
47424
  }
47425
+ const meterProvider = options.meterProvider;
47426
+ const start = meterProvider == null ? 0 : performance.now();
47427
+ let result = "error";
47378
47428
  try {
47379
- return await getActorHandleInternal(actor, options);
47429
+ const handle = await getActorHandleInternal(actor, options);
47430
+ result = "resolved";
47431
+ return handle;
47380
47432
  } catch (error) {
47433
+ result = error instanceof ActorHandleNotFoundError ? "not_found" : "error";
47381
47434
  span.setStatus({
47382
47435
  code: SpanStatusCode.ERROR,
47383
47436
  message: String(error)
47384
47437
  });
47385
47438
  throw error;
47386
47439
  } finally {
47440
+ if (meterProvider != null) {
47441
+ const attributes = { "activitypub.actor.discovery.result": result };
47442
+ const host = getActorDiscoveryRemoteHost(actor);
47443
+ if (host != null) attributes["activitypub.remote.host"] = host;
47444
+ const instruments = getActorDiscoveryInstruments(meterProvider);
47445
+ instruments.discovery.add(1, attributes);
47446
+ instruments.discoveryDuration.record(Math.max(0, performance.now() - start), attributes);
47447
+ }
47387
47448
  span.end();
47388
47449
  }
47389
47450
  });
@@ -47393,7 +47454,8 @@ async function getActorHandleInternal(actor, options = {}) {
47393
47454
  if (actorId != null) {
47394
47455
  const result = await lookupWebFinger(actorId, {
47395
47456
  userAgent: options.userAgent,
47396
- tracerProvider: options.tracerProvider
47457
+ tracerProvider: options.tracerProvider,
47458
+ meterProvider: options.meterProvider
47397
47459
  });
47398
47460
  if (result != null) {
47399
47461
  const aliases = [...result.aliases ?? []];
@@ -47401,19 +47463,20 @@ async function getActorHandleInternal(actor, options = {}) {
47401
47463
  for (const alias of aliases) {
47402
47464
  const match = alias.match(/^acct:([^@]+)@([^@]+)$/);
47403
47465
  if (match != null) {
47404
- if (new URL(`https://${match[2]}/`).hostname !== actorId.hostname && !await verifyCrossOriginActorHandle(actorId.href, alias, options.userAgent, options.tracerProvider)) continue;
47466
+ if (new URL(`https://${match[2]}/`).hostname !== actorId.hostname && !await verifyCrossOriginActorHandle(actorId.href, alias, options.userAgent, options.tracerProvider, options.meterProvider)) continue;
47405
47467
  return normalizeActorHandle(`@${match[1]}@${match[2]}`, options);
47406
47468
  }
47407
47469
  }
47408
47470
  }
47409
47471
  }
47410
47472
  if (!(actor instanceof URL) && actor.preferredUsername != null && actor.id != null) return normalizeActorHandle(`@${actor.preferredUsername}@${actor.id.host}`, options);
47411
- throw new TypeError("Actor does not have enough information to get the handle.");
47473
+ throw new ActorHandleNotFoundError();
47412
47474
  }
47413
- async function verifyCrossOriginActorHandle(actorId, alias, userAgent, tracerProvider) {
47475
+ async function verifyCrossOriginActorHandle(actorId, alias, userAgent, tracerProvider, meterProvider) {
47414
47476
  const response = await lookupWebFinger(alias, {
47415
47477
  userAgent,
47416
- tracerProvider
47478
+ tracerProvider,
47479
+ meterProvider
47417
47480
  });
47418
47481
  if (response == null) return false;
47419
47482
  for (const alias of response.aliases ?? []) if (new URL(alias).href === actorId) return true;
@@ -47647,6 +47710,7 @@ async function lookupObjectInternal(identifier, options = {}) {
47647
47710
  const jrd = await lookupWebFinger(identifier, {
47648
47711
  userAgent: options.userAgent,
47649
47712
  tracerProvider: options.tracerProvider,
47713
+ meterProvider: options.meterProvider,
47650
47714
  allowPrivateAddress: "allowPrivateAddress" in options && options.allowPrivateAddress === true,
47651
47715
  signal: options.signal
47652
47716
  });
@@ -1206,9 +1206,56 @@ var esm_default = new class FetchMock {
1206
1206
  //#endregion
1207
1207
  //#region deno.json
1208
1208
  var name = "@fedify/vocab";
1209
- var version = "2.3.0-dev.1131+553b59b8";
1209
+ var version = "2.3.0-dev.1145+3fe78022";
1210
1210
  //#endregion
1211
1211
  //#region src/actor.ts
1212
+ const ACTOR_DISCOVERY_HISTOGRAM_BUCKETS = [
1213
+ 5,
1214
+ 10,
1215
+ 25,
1216
+ 50,
1217
+ 75,
1218
+ 100,
1219
+ 250,
1220
+ 500,
1221
+ 750,
1222
+ 1e3,
1223
+ 2500,
1224
+ 5e3,
1225
+ 7500,
1226
+ 1e4
1227
+ ];
1228
+ const actorDiscoveryInstruments = /* @__PURE__ */ new WeakMap();
1229
+ function getActorDiscoveryInstruments(meterProvider) {
1230
+ let instruments = actorDiscoveryInstruments.get(meterProvider);
1231
+ if (instruments == null) {
1232
+ const meter = meterProvider.getMeter(name, version);
1233
+ instruments = {
1234
+ discovery: meter.createCounter("activitypub.actor.discovery", {
1235
+ description: "Actor handle discovery attempts via getActorHandle(), classified by terminal outcome.",
1236
+ unit: "{discovery}"
1237
+ }),
1238
+ discoveryDuration: meter.createHistogram("activitypub.actor.discovery.duration", {
1239
+ description: "Duration of getActorHandle() actor discovery calls.",
1240
+ unit: "ms",
1241
+ advice: { explicitBucketBoundaries: [...ACTOR_DISCOVERY_HISTOGRAM_BUCKETS] }
1242
+ })
1243
+ };
1244
+ actorDiscoveryInstruments.set(meterProvider, instruments);
1245
+ }
1246
+ return instruments;
1247
+ }
1248
+ function getActorDiscoveryRemoteHost(actor) {
1249
+ const id = actor instanceof URL ? actor : actor.id;
1250
+ if (id == null) return void 0;
1251
+ return id.hostname === "" ? void 0 : id.hostname;
1252
+ }
1253
+ var ActorHandleNotFoundError = class extends TypeError {
1254
+ constructor() {
1255
+ super("Actor does not have enough information to get the handle.");
1256
+ this.name = "ActorHandleNotFoundError";
1257
+ }
1258
+ };
1212
1259
  /**
1213
1260
  * Checks if the given object is an {@link Actor}.
1214
1261
  * @param object The object to check.
@@ -1275,15 +1322,29 @@ async function getActorHandle(actor, options = {}) {
1275
1322
  if (actor.id != null) span.setAttribute("activitypub.actor.id", actor.id.href);
1276
1323
  span.setAttribute("activitypub.actor.type", getTypeId(actor).href);
1277
1324
  }
1325
+ const meterProvider = options.meterProvider;
1326
+ const start = meterProvider == null ? 0 : performance.now();
1327
+ let result = "error";
1278
1328
  try {
1279
- return await getActorHandleInternal(actor, options);
1329
+ const handle = await getActorHandleInternal(actor, options);
1330
+ result = "resolved";
1331
+ return handle;
1280
1332
  } catch (error) {
1333
+ result = error instanceof ActorHandleNotFoundError ? "not_found" : "error";
1281
1334
  span.setStatus({
1282
1335
  code: SpanStatusCode.ERROR,
1283
1336
  message: String(error)
1284
1337
  });
1285
1338
  throw error;
1286
1339
  } finally {
1340
+ if (meterProvider != null) {
1341
+ const attributes = { "activitypub.actor.discovery.result": result };
1342
+ const host = getActorDiscoveryRemoteHost(actor);
1343
+ if (host != null) attributes["activitypub.remote.host"] = host;
1344
+ const instruments = getActorDiscoveryInstruments(meterProvider);
1345
+ instruments.discovery.add(1, attributes);
1346
+ instruments.discoveryDuration.record(Math.max(0, performance.now() - start), attributes);
1347
+ }
1287
1348
  span.end();
1288
1349
  }
1289
1350
  });
@@ -1293,7 +1354,8 @@ async function getActorHandleInternal(actor, options = {}) {
1293
1354
  if (actorId != null) {
1294
1355
  const result = await lookupWebFinger(actorId, {
1295
1356
  userAgent: options.userAgent,
1296
- tracerProvider: options.tracerProvider
1357
+ tracerProvider: options.tracerProvider,
1358
+ meterProvider: options.meterProvider
1297
1359
  });
1298
1360
  if (result != null) {
1299
1361
  const aliases = [...result.aliases ?? []];
@@ -1301,19 +1363,20 @@ async function getActorHandleInternal(actor, options = {}) {
1301
1363
  for (const alias of aliases) {
1302
1364
  const match = alias.match(/^acct:([^@]+)@([^@]+)$/);
1303
1365
  if (match != null) {
1304
- if (new URL(`https://${match[2]}/`).hostname !== actorId.hostname && !await verifyCrossOriginActorHandle(actorId.href, alias, options.userAgent, options.tracerProvider)) continue;
1366
+ if (new URL(`https://${match[2]}/`).hostname !== actorId.hostname && !await verifyCrossOriginActorHandle(actorId.href, alias, options.userAgent, options.tracerProvider, options.meterProvider)) continue;
1305
1367
  return normalizeActorHandle(`@${match[1]}@${match[2]}`, options);
1306
1368
  }
1307
1369
  }
1308
1370
  }
1309
1371
  }
1310
1372
  if (!(actor instanceof URL) && actor.preferredUsername != null && actor.id != null) return normalizeActorHandle(`@${actor.preferredUsername}@${actor.id.host}`, options);
1311
- throw new TypeError("Actor does not have enough information to get the handle.");
1373
+ throw new ActorHandleNotFoundError();
1312
1374
  }
1313
- async function verifyCrossOriginActorHandle(actorId, alias, userAgent, tracerProvider) {
1375
+ async function verifyCrossOriginActorHandle(actorId, alias, userAgent, tracerProvider, meterProvider) {
1314
1376
  const response = await lookupWebFinger(alias, {
1315
1377
  userAgent,
1316
- tracerProvider
1378
+ tracerProvider,
1379
+ meterProvider
1317
1380
  });
1318
1381
  if (response == null) return false;
1319
1382
  for (const alias of response.aliases ?? []) if (new URL(alias).href === actorId) return true;
@@ -1,8 +1,8 @@
1
1
  import "@js-temporal/polyfill";
2
2
  globalThis.addEventListener = () => {};
3
3
  import { j as __exportAll, r as Application, u as Group, v as Organization, w as Service, y as Person } from "./vocab-B0Z-tH4q.mjs";
4
- import { a as normalizeActorHandle, c as esm_default, i as isActor, n as getActorHandle, r as getActorTypeName, t as getActorClassByTypeName } from "./actor-DBM2eKvJ.mjs";
5
- import { test } from "@fedify/fixture";
4
+ import { a as normalizeActorHandle, c as esm_default, i as isActor, n as getActorHandle, r as getActorTypeName, t as getActorClassByTypeName } from "./actor-B38UQKg6.mjs";
5
+ import { createTestMeterProvider, test } from "@fedify/fixture";
6
6
  import { deepStrictEqual, ok, rejects, strictEqual, throws } from "node:assert/strict";
7
7
  //#region ../../node_modules/.pnpm/fast-check@3.23.2/node_modules/fast-check/lib/esm/check/precondition/PreconditionFailure.js
8
8
  var PreconditionFailure = class PreconditionFailure extends Error {
@@ -5691,6 +5691,103 @@ test({
5691
5691
  esm_default.hardReset();
5692
5692
  }
5693
5693
  });
5694
+ test("getActorHandle() records activitypub.actor.discovery counter", { permissions: {
5695
+ env: true,
5696
+ read: true
5697
+ } }, async (t) => {
5698
+ esm_default.spyGlobal();
5699
+ try {
5700
+ const actorId = new URL("https://foo.example.com/@john");
5701
+ const actor = new Person({
5702
+ id: actorId,
5703
+ preferredUsername: "john"
5704
+ });
5705
+ await t.step("records result=resolved on a successful lookup", async () => {
5706
+ esm_default.removeRoutes();
5707
+ esm_default.get("begin:https://foo.example.com/.well-known/webfinger?", {
5708
+ body: { subject: "acct:johndoe@foo.example.com" },
5709
+ headers: { "Content-Type": "application/jrd+json" }
5710
+ });
5711
+ const [meterProvider, recorder] = createTestMeterProvider();
5712
+ deepStrictEqual(await getActorHandle(actor, { meterProvider }), "@johndoe@foo.example.com");
5713
+ const counters = recorder.getMeasurements("activitypub.actor.discovery");
5714
+ deepStrictEqual(counters.length, 1);
5715
+ deepStrictEqual(counters[0].type, "counter");
5716
+ deepStrictEqual(counters[0].value, 1);
5717
+ deepStrictEqual(counters[0].attributes["activitypub.actor.discovery.result"], "resolved");
5718
+ deepStrictEqual(counters[0].attributes["activitypub.remote.host"], "foo.example.com");
5719
+ const durations = recorder.getMeasurements("activitypub.actor.discovery.duration");
5720
+ deepStrictEqual(durations.length, 1);
5721
+ deepStrictEqual(durations[0].type, "histogram");
5722
+ deepStrictEqual(durations[0].attributes["activitypub.actor.discovery.result"], "resolved");
5723
+ ok(typeof durations[0].value === "number" && durations[0].value >= 0);
5724
+ });
5725
+ await t.step("records result=resolved when WebFinger is missing but preferredUsername fallback succeeds", async () => {
5726
+ esm_default.removeRoutes();
5727
+ esm_default.get("begin:https://foo.example.com/.well-known/webfinger?", { status: 404 });
5728
+ const [meterProvider, recorder] = createTestMeterProvider();
5729
+ deepStrictEqual(await getActorHandle(actor, { meterProvider }), "@john@foo.example.com");
5730
+ const counter = recorder.getMeasurement("activitypub.actor.discovery");
5731
+ ok(counter != null);
5732
+ deepStrictEqual(counter.attributes["activitypub.actor.discovery.result"], "resolved");
5733
+ });
5734
+ await t.step("records result=not_found when neither WebFinger nor preferredUsername yields a handle", async () => {
5735
+ esm_default.removeRoutes();
5736
+ esm_default.get("begin:https://foo.example.com/.well-known/webfinger?", { status: 404 });
5737
+ const [meterProvider, recorder] = createTestMeterProvider();
5738
+ await rejects(() => getActorHandle(actorId, { meterProvider }), TypeError);
5739
+ const counter = recorder.getMeasurement("activitypub.actor.discovery");
5740
+ ok(counter != null);
5741
+ deepStrictEqual(counter.attributes["activitypub.actor.discovery.result"], "not_found");
5742
+ deepStrictEqual(counter.attributes["activitypub.remote.host"], "foo.example.com");
5743
+ const duration = recorder.getMeasurement("activitypub.actor.discovery.duration");
5744
+ ok(duration != null);
5745
+ deepStrictEqual(duration.attributes["activitypub.actor.discovery.result"], "not_found");
5746
+ });
5747
+ await t.step("records result=error when a malformed WebFinger alias throws TypeError", async () => {
5748
+ esm_default.removeRoutes();
5749
+ esm_default.get("begin:https://foo.example.com/.well-known/webfinger?", {
5750
+ body: {
5751
+ subject: "https://foo.example.com/@john",
5752
+ aliases: ["acct:john@["]
5753
+ },
5754
+ headers: { "Content-Type": "application/jrd+json" }
5755
+ });
5756
+ const [meterProvider, recorder] = createTestMeterProvider();
5757
+ await rejects(() => getActorHandle(actorId, { meterProvider }), TypeError);
5758
+ const counter = recorder.getMeasurement("activitypub.actor.discovery");
5759
+ ok(counter != null);
5760
+ deepStrictEqual(counter.attributes["activitypub.actor.discovery.result"], "error");
5761
+ });
5762
+ await t.step("propagates meterProvider into the nested webfinger.lookup", async () => {
5763
+ esm_default.removeRoutes();
5764
+ esm_default.get("begin:https://foo.example.com/.well-known/webfinger?", {
5765
+ body: { subject: "acct:johndoe@foo.example.com" },
5766
+ headers: { "Content-Type": "application/jrd+json" }
5767
+ });
5768
+ const [meterProvider, recorder] = createTestMeterProvider();
5769
+ await getActorHandle(actor, { meterProvider });
5770
+ const webFingerCounter = recorder.getMeasurement("webfinger.lookup");
5771
+ ok(webFingerCounter != null);
5772
+ deepStrictEqual(webFingerCounter.attributes["webfinger.lookup.result"], "found");
5773
+ });
5774
+ await t.step("omits measurements when no meterProvider is provided", async () => {
5775
+ esm_default.removeRoutes();
5776
+ esm_default.get("begin:https://foo.example.com/.well-known/webfinger?", {
5777
+ body: { subject: "acct:johndoe@foo.example.com" },
5778
+ headers: { "Content-Type": "application/jrd+json" }
5779
+ });
5780
+ const [_unused, recorder] = createTestMeterProvider();
5781
+ await getActorHandle(actor);
5782
+ deepStrictEqual(recorder.getMeasurements("activitypub.actor.discovery").length, 0);
5783
+ deepStrictEqual(recorder.getMeasurements("activitypub.actor.discovery.duration").length, 0);
5784
+ deepStrictEqual(recorder.getMeasurements("webfinger.lookup").length, 0);
5785
+ });
5786
+ } finally {
5787
+ esm_default.removeRoutes();
5788
+ esm_default.hardReset();
5789
+ }
5790
+ });
5694
5791
  test("normalizeActorHandle()", () => {
5695
5792
  deepStrictEqual(normalizeActorHandle("@foo@BAR.COM"), "@foo@bar.com");
5696
5793
  deepStrictEqual(normalizeActorHandle("@BAZ@☃-⌘.com"), "@BAZ@☃-⌘.com");
@@ -1,7 +1,7 @@
1
1
  import { Temporal } from "@js-temporal/polyfill";
2
2
  globalThis.addEventListener = () => {};
3
3
  import { g as Object$1, h as Note, i as Collection, y as Person } from "./vocab-B0Z-tH4q.mjs";
4
- import { c as esm_default, i as isActor, o as name, s as version } from "./actor-DBM2eKvJ.mjs";
4
+ import { c as esm_default, i as isActor, o as name, s as version } from "./actor-B38UQKg6.mjs";
5
5
  import { t as getTypeId } from "./type-Cf-vxmre.mjs";
6
6
  import { t as assertInstanceOf } from "./utils-CE8Dk5hm.mjs";
7
7
  import { createTestMeterProvider, createTestTracerProvider, mockDocumentLoader, test } from "@fedify/fixture";
@@ -186,6 +186,7 @@ async function lookupObjectInternal(identifier, options = {}) {
186
186
  const jrd = await lookupWebFinger(identifier, {
187
187
  userAgent: options.userAgent,
188
188
  tracerProvider: options.tracerProvider,
189
+ meterProvider: options.meterProvider,
189
190
  allowPrivateAddress: "allowPrivateAddress" in options && options.allowPrivateAddress === true,
190
191
  signal: options.signal
191
192
  });
@@ -781,6 +782,27 @@ test("lookupObject() records activitypub.object.lookup counter", {
781
782
  });
782
783
  deepStrictEqual(recorder.getMeasurements("activitypub.object.lookup").length, 0);
783
784
  });
785
+ await t.step("propagates meterProvider into the nested webfinger.lookup", async () => {
786
+ esm_default.removeRoutes();
787
+ esm_default.get("begin:https://example.com/.well-known/webfinger", {
788
+ subject: "acct:johndoe@example.com",
789
+ links: [{
790
+ rel: "self",
791
+ href: "https://example.com/person",
792
+ type: "application/activity+json"
793
+ }]
794
+ });
795
+ const [meterProvider, recorder] = createTestMeterProvider();
796
+ await lookupObject("@johndoe@example.com", {
797
+ documentLoader: mockDocumentLoader,
798
+ contextLoader: mockDocumentLoader,
799
+ meterProvider
800
+ });
801
+ const webFingerCounter = recorder.getMeasurement("webfinger.lookup");
802
+ ok(webFingerCounter != null);
803
+ deepStrictEqual(webFingerCounter.attributes["webfinger.lookup.result"], "found");
804
+ deepStrictEqual(webFingerCounter.attributes["activitypub.remote.host"], "example.com");
805
+ });
784
806
  await t.step("extracts host from a URL acct: instance", async () => {
785
807
  esm_default.removeRoutes();
786
808
  esm_default.get("begin:https://example.com/.well-known/webfinger", {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fedify/vocab",
3
- "version": "2.3.0-dev.1131+553b59b8",
3
+ "version": "2.3.0-dev.1145+3fe78022",
4
4
  "homepage": "https://fedify.dev/",
5
5
  "repository": {
6
6
  "type": "git",
@@ -46,9 +46,9 @@
46
46
  "es-toolkit": "1.46.1",
47
47
  "jsonld": "^9.0.0",
48
48
  "pkijs": "^3.3.3",
49
- "@fedify/vocab-tools": "2.3.0-dev.1131+553b59b8",
50
- "@fedify/webfinger": "2.3.0-dev.1131+553b59b8",
51
- "@fedify/vocab-runtime": "2.3.0-dev.1131+553b59b8"
49
+ "@fedify/vocab-tools": "2.3.0-dev.1145+3fe78022",
50
+ "@fedify/webfinger": "2.3.0-dev.1145+3fe78022",
51
+ "@fedify/vocab-runtime": "2.3.0-dev.1145+3fe78022"
52
52
  },
53
53
  "devDependencies": {
54
54
  "@types/node": "^22.17.0",
package/src/actor.test.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { test } from "@fedify/fixture";
1
+ import { createTestMeterProvider, test } from "@fedify/fixture";
2
2
  import * as fc from "fast-check";
3
3
  import fetchMock from "fetch-mock";
4
4
  import {
@@ -192,6 +192,202 @@ test({
192
192
  },
193
193
  });
194
194
 
195
+ test("getActorHandle() records activitypub.actor.discovery counter", {
196
+ permissions: { env: true, read: true },
197
+ }, async (t) => {
198
+ fetchMock.spyGlobal();
199
+ try {
200
+ const actorId = new URL("https://foo.example.com/@john");
201
+ const actor = new Person({
202
+ id: actorId,
203
+ preferredUsername: "john",
204
+ });
205
+
206
+ await t.step("records result=resolved on a successful lookup", async () => {
207
+ fetchMock.removeRoutes();
208
+ fetchMock.get(
209
+ "begin:https://foo.example.com/.well-known/webfinger?",
210
+ {
211
+ body: { subject: "acct:johndoe@foo.example.com" },
212
+ headers: { "Content-Type": "application/jrd+json" },
213
+ },
214
+ );
215
+ const [meterProvider, recorder] = createTestMeterProvider();
216
+ const handle = await getActorHandle(actor, { meterProvider });
217
+ deepStrictEqual(handle, "@johndoe@foo.example.com");
218
+
219
+ const counters = recorder.getMeasurements(
220
+ "activitypub.actor.discovery",
221
+ );
222
+ deepStrictEqual(counters.length, 1);
223
+ deepStrictEqual(counters[0].type, "counter");
224
+ deepStrictEqual(counters[0].value, 1);
225
+ deepStrictEqual(
226
+ counters[0].attributes["activitypub.actor.discovery.result"],
227
+ "resolved",
228
+ );
229
+ deepStrictEqual(
230
+ counters[0].attributes["activitypub.remote.host"],
231
+ "foo.example.com",
232
+ );
233
+
234
+ const durations = recorder.getMeasurements(
235
+ "activitypub.actor.discovery.duration",
236
+ );
237
+ deepStrictEqual(durations.length, 1);
238
+ deepStrictEqual(durations[0].type, "histogram");
239
+ deepStrictEqual(
240
+ durations[0].attributes["activitypub.actor.discovery.result"],
241
+ "resolved",
242
+ );
243
+ ok(typeof durations[0].value === "number" && durations[0].value >= 0);
244
+ });
245
+
246
+ await t.step(
247
+ "records result=resolved when WebFinger is missing but preferredUsername fallback succeeds",
248
+ async () => {
249
+ fetchMock.removeRoutes();
250
+ fetchMock.get(
251
+ "begin:https://foo.example.com/.well-known/webfinger?",
252
+ { status: 404 },
253
+ );
254
+ const [meterProvider, recorder] = createTestMeterProvider();
255
+ const handle = await getActorHandle(actor, { meterProvider });
256
+ deepStrictEqual(handle, "@john@foo.example.com");
257
+ const counter = recorder.getMeasurement("activitypub.actor.discovery");
258
+ ok(counter != null);
259
+ deepStrictEqual(
260
+ counter.attributes["activitypub.actor.discovery.result"],
261
+ "resolved",
262
+ );
263
+ },
264
+ );
265
+
266
+ await t.step(
267
+ "records result=not_found when neither WebFinger nor preferredUsername yields a handle",
268
+ async () => {
269
+ fetchMock.removeRoutes();
270
+ fetchMock.get(
271
+ "begin:https://foo.example.com/.well-known/webfinger?",
272
+ { status: 404 },
273
+ );
274
+ const [meterProvider, recorder] = createTestMeterProvider();
275
+ await rejects(
276
+ () => getActorHandle(actorId, { meterProvider }),
277
+ TypeError,
278
+ );
279
+ const counter = recorder.getMeasurement("activitypub.actor.discovery");
280
+ ok(counter != null);
281
+ deepStrictEqual(
282
+ counter.attributes["activitypub.actor.discovery.result"],
283
+ "not_found",
284
+ );
285
+ deepStrictEqual(
286
+ counter.attributes["activitypub.remote.host"],
287
+ "foo.example.com",
288
+ );
289
+ const duration = recorder.getMeasurement(
290
+ "activitypub.actor.discovery.duration",
291
+ );
292
+ ok(duration != null);
293
+ deepStrictEqual(
294
+ duration.attributes["activitypub.actor.discovery.result"],
295
+ "not_found",
296
+ );
297
+ },
298
+ );
299
+
300
+ await t.step(
301
+ "records result=error when a malformed WebFinger alias throws TypeError",
302
+ async () => {
303
+ // The "[" byte makes `new URL("https://[/")` throw `TypeError`
304
+ // when getActorHandleInternal attempts to parse the alias host.
305
+ // This TypeError is a malformed-remote-data failure, not the
306
+ // "actor lacks information" sentinel, so the metric must record
307
+ // `error` rather than `not_found`.
308
+ fetchMock.removeRoutes();
309
+ fetchMock.get(
310
+ "begin:https://foo.example.com/.well-known/webfinger?",
311
+ {
312
+ body: {
313
+ subject: "https://foo.example.com/@john",
314
+ aliases: ["acct:john@["],
315
+ },
316
+ headers: { "Content-Type": "application/jrd+json" },
317
+ },
318
+ );
319
+ const [meterProvider, recorder] = createTestMeterProvider();
320
+ await rejects(
321
+ () => getActorHandle(actorId, { meterProvider }),
322
+ TypeError,
323
+ );
324
+ const counter = recorder.getMeasurement(
325
+ "activitypub.actor.discovery",
326
+ );
327
+ ok(counter != null);
328
+ deepStrictEqual(
329
+ counter.attributes["activitypub.actor.discovery.result"],
330
+ "error",
331
+ );
332
+ },
333
+ );
334
+
335
+ await t.step(
336
+ "propagates meterProvider into the nested webfinger.lookup",
337
+ async () => {
338
+ fetchMock.removeRoutes();
339
+ fetchMock.get(
340
+ "begin:https://foo.example.com/.well-known/webfinger?",
341
+ {
342
+ body: { subject: "acct:johndoe@foo.example.com" },
343
+ headers: { "Content-Type": "application/jrd+json" },
344
+ },
345
+ );
346
+ const [meterProvider, recorder] = createTestMeterProvider();
347
+ await getActorHandle(actor, { meterProvider });
348
+ const webFingerCounter = recorder.getMeasurement("webfinger.lookup");
349
+ ok(webFingerCounter != null);
350
+ deepStrictEqual(
351
+ webFingerCounter.attributes["webfinger.lookup.result"],
352
+ "found",
353
+ );
354
+ },
355
+ );
356
+
357
+ await t.step(
358
+ "omits measurements when no meterProvider is provided",
359
+ async () => {
360
+ fetchMock.removeRoutes();
361
+ fetchMock.get(
362
+ "begin:https://foo.example.com/.well-known/webfinger?",
363
+ {
364
+ body: { subject: "acct:johndoe@foo.example.com" },
365
+ headers: { "Content-Type": "application/jrd+json" },
366
+ },
367
+ );
368
+ const [_unused, recorder] = createTestMeterProvider();
369
+ await getActorHandle(actor);
370
+ deepStrictEqual(
371
+ recorder.getMeasurements("activitypub.actor.discovery").length,
372
+ 0,
373
+ );
374
+ deepStrictEqual(
375
+ recorder.getMeasurements("activitypub.actor.discovery.duration")
376
+ .length,
377
+ 0,
378
+ );
379
+ deepStrictEqual(
380
+ recorder.getMeasurements("webfinger.lookup").length,
381
+ 0,
382
+ );
383
+ },
384
+ );
385
+ } finally {
386
+ fetchMock.removeRoutes();
387
+ fetchMock.hardReset();
388
+ }
389
+ });
390
+
195
391
  test("normalizeActorHandle()", () => {
196
392
  deepStrictEqual(normalizeActorHandle("@foo@BAR.COM"), "@foo@bar.com");
197
393
  deepStrictEqual(normalizeActorHandle("@BAZ@☃-⌘.com"), "@BAZ@☃-⌘.com");
package/src/actor.ts CHANGED
@@ -1,11 +1,114 @@
1
1
  import type { GetUserAgentOptions } from "@fedify/vocab-runtime";
2
2
  import { lookupWebFinger } from "@fedify/webfinger";
3
- import { SpanStatusCode, trace, type TracerProvider } from "@opentelemetry/api";
3
+ import {
4
+ type Attributes,
5
+ type Counter,
6
+ type Histogram,
7
+ type MeterProvider,
8
+ SpanStatusCode,
9
+ trace,
10
+ type TracerProvider,
11
+ } from "@opentelemetry/api";
4
12
  import { domainToASCII, domainToUnicode } from "node:url";
5
13
  import metadata from "../deno.json" with { type: "json" };
6
14
  import { getTypeId } from "./type.ts";
7
15
  import { Application, Group, Organization, Person, Service } from "./vocab.ts";
8
16
 
17
+ /**
18
+ * The terminal classification of a {@link getActorHandle} call, recorded as
19
+ * the `activitypub.actor.discovery.result` attribute on the
20
+ * `activitypub.actor.discovery` counter and
21
+ * `activitypub.actor.discovery.duration` histogram.
22
+ *
23
+ * - `resolved`: a handle was returned to the caller.
24
+ * - `not_found`: WebFinger did not yield a usable `acct:` alias and the
25
+ * `preferredUsername` fallback could not run. This corresponds to the
26
+ * `TypeError("Actor does not have enough information…")` path.
27
+ * - `error`: any other thrown exception bubbled up from the lookup.
28
+ *
29
+ * Per-WebFinger-call failure detail (HTTP status, parse failure, network
30
+ * failure, etc.) lives on `webfinger.lookup` and is intentionally not
31
+ * duplicated here.
32
+ * @since 2.3.0
33
+ */
34
+ export type ActorDiscoveryResult = "resolved" | "not_found" | "error";
35
+
36
+ interface ActorDiscoveryInstruments {
37
+ discovery: Counter;
38
+ discoveryDuration: Histogram;
39
+ }
40
+
41
+ const ACTOR_DISCOVERY_HISTOGRAM_BUCKETS: ReadonlyArray<number> = [
42
+ 5,
43
+ 10,
44
+ 25,
45
+ 50,
46
+ 75,
47
+ 100,
48
+ 250,
49
+ 500,
50
+ 750,
51
+ 1000,
52
+ 2500,
53
+ 5000,
54
+ 7500,
55
+ 10000,
56
+ ];
57
+
58
+ const actorDiscoveryInstruments = new WeakMap<
59
+ MeterProvider,
60
+ ActorDiscoveryInstruments
61
+ >();
62
+
63
+ function getActorDiscoveryInstruments(
64
+ meterProvider: MeterProvider,
65
+ ): ActorDiscoveryInstruments {
66
+ let instruments = actorDiscoveryInstruments.get(meterProvider);
67
+ if (instruments == null) {
68
+ const meter = meterProvider.getMeter(metadata.name, metadata.version);
69
+ instruments = {
70
+ discovery: meter.createCounter("activitypub.actor.discovery", {
71
+ description: "Actor handle discovery attempts via getActorHandle(), " +
72
+ "classified by terminal outcome.",
73
+ unit: "{discovery}",
74
+ }),
75
+ discoveryDuration: meter.createHistogram(
76
+ "activitypub.actor.discovery.duration",
77
+ {
78
+ description: "Duration of getActorHandle() actor discovery calls.",
79
+ unit: "ms",
80
+ advice: {
81
+ explicitBucketBoundaries: [...ACTOR_DISCOVERY_HISTOGRAM_BUCKETS],
82
+ },
83
+ },
84
+ ),
85
+ };
86
+ actorDiscoveryInstruments.set(meterProvider, instruments);
87
+ }
88
+ return instruments;
89
+ }
90
+
91
+ function getActorDiscoveryRemoteHost(
92
+ actor: Actor | URL,
93
+ ): string | undefined {
94
+ const id = actor instanceof URL ? actor : actor.id;
95
+ if (id == null) return undefined;
96
+ return id.hostname === "" ? undefined : id.hostname;
97
+ }
98
+
99
+ // Subclass of TypeError that preserves the documented `throws {TypeError}`
100
+ // API contract while letting the actor-discovery metric distinguish the
101
+ // "actor lacks enough information to derive a handle" terminal path from
102
+ // other TypeError-shaped failures that can come from malformed remote data
103
+ // (e.g. an alias that does not parse as a URL, or an invalid preferred
104
+ // username that breaks normalizeActorHandle).
105
+ class ActorHandleNotFoundError extends TypeError {
106
+ constructor() {
107
+ super("Actor does not have enough information to get the handle.");
108
+ this.name = "ActorHandleNotFoundError";
109
+ }
110
+ }
111
+
9
112
  /**
10
113
  * Actor types are {@link Object} types that are capable of performing
11
114
  * activities.
@@ -101,6 +204,19 @@ export interface GetActorHandleOptions extends NormalizeActorHandleOptions {
101
204
  * @since 1.3.0
102
205
  */
103
206
  tracerProvider?: TracerProvider;
207
+
208
+ /**
209
+ * The OpenTelemetry meter provider used to record the
210
+ * `activitypub.actor.discovery` counter and
211
+ * `activitypub.actor.discovery.duration` histogram. When set, the same
212
+ * meter provider is also forwarded to the nested WebFinger lookups so
213
+ * each discovery emits both the actor-discovery measurements and the
214
+ * underlying `webfinger.lookup` measurements. If omitted, no
215
+ * measurements are emitted (the helper is opt-in to avoid touching the
216
+ * global meter provider for callers that do not use OpenTelemetry).
217
+ * @since 2.3.0
218
+ */
219
+ meterProvider?: MeterProvider;
104
220
  }
105
221
 
106
222
  /**
@@ -145,15 +261,36 @@ export async function getActorHandle(
145
261
  }
146
262
  span.setAttribute("activitypub.actor.type", getTypeId(actor).href);
147
263
  }
264
+ const meterProvider = options.meterProvider;
265
+ const start = meterProvider == null ? 0 : performance.now();
266
+ let result: ActorDiscoveryResult = "error";
148
267
  try {
149
- return await getActorHandleInternal(actor, options);
268
+ const handle = await getActorHandleInternal(actor, options);
269
+ result = "resolved";
270
+ return handle;
150
271
  } catch (error) {
272
+ result = error instanceof ActorHandleNotFoundError
273
+ ? "not_found"
274
+ : "error";
151
275
  span.setStatus({
152
276
  code: SpanStatusCode.ERROR,
153
277
  message: String(error),
154
278
  });
155
279
  throw error;
156
280
  } finally {
281
+ if (meterProvider != null) {
282
+ const attributes: Attributes = {
283
+ "activitypub.actor.discovery.result": result,
284
+ };
285
+ const host = getActorDiscoveryRemoteHost(actor);
286
+ if (host != null) attributes["activitypub.remote.host"] = host;
287
+ const instruments = getActorDiscoveryInstruments(meterProvider);
288
+ instruments.discovery.add(1, attributes);
289
+ instruments.discoveryDuration.record(
290
+ Math.max(0, performance.now() - start),
291
+ attributes,
292
+ );
293
+ }
157
294
  span.end();
158
295
  }
159
296
  },
@@ -169,6 +306,7 @@ async function getActorHandleInternal(
169
306
  const result = await lookupWebFinger(actorId, {
170
307
  userAgent: options.userAgent,
171
308
  tracerProvider: options.tracerProvider,
309
+ meterProvider: options.meterProvider,
172
310
  });
173
311
  if (result != null) {
174
312
  const aliases = [...(result.aliases ?? [])];
@@ -184,6 +322,7 @@ async function getActorHandleInternal(
184
322
  alias,
185
323
  options.userAgent,
186
324
  options.tracerProvider,
325
+ options.meterProvider,
187
326
  )
188
327
  ) {
189
328
  continue;
@@ -202,9 +341,7 @@ async function getActorHandleInternal(
202
341
  options,
203
342
  );
204
343
  }
205
- throw new TypeError(
206
- "Actor does not have enough information to get the handle.",
207
- );
344
+ throw new ActorHandleNotFoundError();
208
345
  }
209
346
 
210
347
  async function verifyCrossOriginActorHandle(
@@ -212,8 +349,13 @@ async function verifyCrossOriginActorHandle(
212
349
  alias: string,
213
350
  userAgent: GetUserAgentOptions | string | undefined,
214
351
  tracerProvider: TracerProvider | undefined,
352
+ meterProvider: MeterProvider | undefined,
215
353
  ): Promise<boolean> {
216
- const response = await lookupWebFinger(alias, { userAgent, tracerProvider });
354
+ const response = await lookupWebFinger(alias, {
355
+ userAgent,
356
+ tracerProvider,
357
+ meterProvider,
358
+ });
217
359
  if (response == null) return false;
218
360
  for (const alias of response.aliases ?? []) {
219
361
  if (new URL(alias).href === actorId) return true;
@@ -811,6 +811,42 @@ test("lookupObject() records activitypub.object.lookup counter", {
811
811
  },
812
812
  );
813
813
 
814
+ await t.step(
815
+ "propagates meterProvider into the nested webfinger.lookup",
816
+ async () => {
817
+ fetchMock.removeRoutes();
818
+ fetchMock.get(
819
+ "begin:https://example.com/.well-known/webfinger",
820
+ {
821
+ subject: "acct:johndoe@example.com",
822
+ links: [
823
+ {
824
+ rel: "self",
825
+ href: "https://example.com/person",
826
+ type: "application/activity+json",
827
+ },
828
+ ],
829
+ },
830
+ );
831
+ const [meterProvider, recorder] = createTestMeterProvider();
832
+ await lookupObject("@johndoe@example.com", {
833
+ documentLoader: mockDocumentLoader,
834
+ contextLoader: mockDocumentLoader,
835
+ meterProvider,
836
+ });
837
+ const webFingerCounter = recorder.getMeasurement("webfinger.lookup");
838
+ ok(webFingerCounter != null);
839
+ deepStrictEqual(
840
+ webFingerCounter.attributes["webfinger.lookup.result"],
841
+ "found",
842
+ );
843
+ deepStrictEqual(
844
+ webFingerCounter.attributes["activitypub.remote.host"],
845
+ "example.com",
846
+ );
847
+ },
848
+ );
849
+
814
850
  await t.step(
815
851
  "extracts host from a URL acct: instance",
816
852
  async () => {
package/src/lookup.ts CHANGED
@@ -286,6 +286,7 @@ async function lookupObjectInternal(
286
286
  const jrd = await lookupWebFinger(identifier, {
287
287
  userAgent: options.userAgent,
288
288
  tracerProvider: options.tracerProvider,
289
+ meterProvider: options.meterProvider,
289
290
  allowPrivateAddress: "allowPrivateAddress" in options &&
290
291
  options.allowPrivateAddress === true,
291
292
  signal: options.signal,