From 1b57c9d5184b500f013b73639c98401603692b41 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 25 May 2026 02:21:35 +0900 Subject: [PATCH 01/10] Add collection metric instruments Add Fedify collection metric instruments and helper functions so collection request, dispatcher duration, page item count, and total item count measurements share a bounded attribute set. https://github.com/fedify-dev/fedify/issues/316 https://github.com/fedify-dev/fedify/issues/741 Assisted-by: Codex:gpt-5.5 --- .../fedify/src/federation/metrics.test.ts | 104 +++++++++ packages/fedify/src/federation/metrics.ts | 208 ++++++++++++++++++ 2 files changed, 312 insertions(+) diff --git a/packages/fedify/src/federation/metrics.test.ts b/packages/fedify/src/federation/metrics.test.ts index 1c89cd441..541f1ea17 100644 --- a/packages/fedify/src/federation/metrics.test.ts +++ b/packages/fedify/src/federation/metrics.test.ts @@ -6,6 +6,10 @@ import type { MessageQueue } from "./mq.ts"; import { classifyFetchError, instrumentDocumentLoader, + recordCollectionDispatchDuration, + recordCollectionPageItems, + recordCollectionRequest, + recordCollectionTotalItems, recordDocumentCache, recordDocumentFetch, recordFanoutRecipients, @@ -390,6 +394,106 @@ test("recordWebFingerHandle() omits optional attributes when not provided", () = ); }); +test("recordCollectionRequest() records counter with bounded attributes", () => { + const [meterProvider, recorder] = createTestMeterProvider(); + recordCollectionRequest(meterProvider, { + kind: "followers", + page: true, + dispatcher: "built_in", + result: "served", + statusCode: 200, + }); + + const counter = recorder.getMeasurement("activitypub.collection.request"); + assertEquals(counter?.type, "counter"); + assertEquals(counter?.value, 1); + assertEquals(counter?.attributes["activitypub.collection.kind"], "followers"); + assertEquals(counter?.attributes["activitypub.collection.page"], true); + assertEquals(counter?.attributes["fedify.collection.dispatcher"], "built_in"); + assertEquals(counter?.attributes["activitypub.collection.result"], "served"); + assertEquals(counter?.attributes["http.response.status_code"], 200); +}); + +test("recordCollectionRequest() omits status code when unavailable", () => { + const [meterProvider, recorder] = createTestMeterProvider(); + recordCollectionRequest(meterProvider, { + kind: "custom", + page: false, + dispatcher: "custom", + result: "error", + }); + + const counter = recorder.getMeasurement("activitypub.collection.request"); + assertEquals(counter?.attributes["activitypub.collection.kind"], "custom"); + assertEquals(counter?.attributes["activitypub.collection.page"], false); + assertEquals(counter?.attributes["fedify.collection.dispatcher"], "custom"); + assertEquals(counter?.attributes["activitypub.collection.result"], "error"); + assertEquals( + "http.response.status_code" in (counter?.attributes ?? {}), + false, + ); +}); + +test("recordCollectionDispatchDuration() records histogram", () => { + const [meterProvider, recorder] = createTestMeterProvider(); + recordCollectionDispatchDuration(meterProvider, 12, { + kind: "outbox", + page: false, + dispatcher: "built_in", + result: "served", + }); + + const duration = recorder.getMeasurement( + "activitypub.collection.dispatch.duration", + ); + assertEquals(duration?.type, "histogram"); + assertEquals(duration?.value, 12); + assertEquals(duration?.attributes["activitypub.collection.kind"], "outbox"); + assertEquals(duration?.attributes["activitypub.collection.page"], false); + assertEquals( + duration?.attributes["fedify.collection.dispatcher"], + "built_in", + ); + assertEquals(duration?.attributes["activitypub.collection.result"], "served"); +}); + +test("recordCollectionPageItems() records item count histogram", () => { + const [meterProvider, recorder] = createTestMeterProvider(); + recordCollectionPageItems(meterProvider, 3, { + kind: "featured_tags", + page: true, + dispatcher: "built_in", + result: "served", + statusCode: 200, + }); + + const items = recorder.getMeasurement("activitypub.collection.page.items"); + assertEquals(items?.type, "histogram"); + assertEquals(items?.value, 3); + assertEquals( + items?.attributes["activitypub.collection.kind"], + "featured_tags", + ); + assertEquals(items?.attributes["activitypub.collection.page"], true); + assertEquals(items?.attributes["http.response.status_code"], 200); +}); + +test("recordCollectionTotalItems() records total item histogram", () => { + const [meterProvider, recorder] = createTestMeterProvider(); + recordCollectionTotalItems(meterProvider, 42, { + kind: "liked", + page: false, + dispatcher: "built_in", + result: "served", + }); + + const total = recorder.getMeasurement("activitypub.collection.total_items"); + assertEquals(total?.type, "histogram"); + assertEquals(total?.value, 42); + assertEquals(total?.attributes["activitypub.collection.kind"], "liked"); + assertEquals(total?.attributes["activitypub.collection.page"], false); +}); + test("classifyFetchError() classifies FetchError with 404 as not_found", () => { const response = new Response("", { status: 404 }); const error = new FetchError( diff --git a/packages/fedify/src/federation/metrics.ts b/packages/fedify/src/federation/metrics.ts index ff0c994fd..f13c64cb9 100644 --- a/packages/fedify/src/federation/metrics.ts +++ b/packages/fedify/src/federation/metrics.ts @@ -409,6 +409,51 @@ export interface WebFingerHandleAttributes { statusCode?: number; } +/** + * The bounded collection kind recorded on collection request metrics. + * @since 2.3.0 + */ +export type CollectionMetricKind = + | "inbox" + | "outbox" + | "following" + | "followers" + | "liked" + | "featured" + | "featured_tags" + | "custom"; + +/** + * The terminal request classification recorded on collection metrics. + * @since 2.3.0 + */ +export type CollectionMetricResult = + | "served" + | "not_found" + | "not_acceptable" + | "unauthorized" + | "error"; + +/** + * Whether a collection request was handled by one of Fedify's built-in + * ActivityPub collection dispatchers or by an application-defined custom + * collection dispatcher. + * @since 2.3.0 + */ +export type CollectionMetricDispatcher = "built_in" | "custom"; + +/** + * Common attributes accepted by collection metric helpers. + * @since 2.3.0 + */ +export interface CollectionMetricAttributes { + kind: CollectionMetricKind; + page: boolean; + dispatcher: CollectionMetricDispatcher; + result: CollectionMetricResult; + statusCode?: number; +} + class FederationMetrics { readonly deliverySent: Counter; readonly deliveryPermanentFailure: Counter; @@ -435,6 +480,10 @@ class FederationMetrics { readonly documentCache: Counter; readonly webFingerHandle: Counter; readonly webFingerHandleDuration: Histogram; + readonly collectionRequest: Counter; + readonly collectionDispatchDuration: Histogram; + readonly collectionPageItems: Histogram; + readonly collectionTotalItems: Histogram; constructor(meterProvider: MeterProvider) { const meter = meterProvider.getMeter(metadata.name, metadata.version); @@ -709,6 +758,56 @@ class FederationMetrics { }, }, ); + this.collectionRequest = meter.createCounter( + "activitypub.collection.request", + { + description: + "ActivityPub collection and collection-page requests handled by " + + "Fedify.", + unit: "{request}", + }, + ); + this.collectionDispatchDuration = meter.createHistogram( + "activitypub.collection.dispatch.duration", + { + description: "Duration of ActivityPub collection dispatcher callbacks.", + unit: "ms", + advice: { + explicitBucketBoundaries: [ + 5, + 10, + 25, + 50, + 75, + 100, + 250, + 500, + 750, + 1000, + 2500, + 5000, + 7500, + 10000, + ], + }, + }, + ); + this.collectionPageItems = meter.createHistogram( + "activitypub.collection.page.items", + { + description: "Number of items Fedify materialized for an ActivityPub " + + "collection response.", + unit: "{item}", + }, + ); + this.collectionTotalItems = meter.createHistogram( + "activitypub.collection.total_items", + { + description: + "Total item count reported by ActivityPub collection counters.", + unit: "{item}", + }, + ); } recordDelivery( @@ -935,6 +1034,55 @@ class FederationMetrics { this.webFingerHandle.add(1, attributes); this.webFingerHandleDuration.record(attrs.durationMs, attributes); } + + recordCollectionRequest(attrs: CollectionMetricAttributes): void { + this.collectionRequest.add(1, buildCollectionAttributes(attrs)); + } + + recordCollectionDispatchDuration( + durationMs: number, + attrs: CollectionMetricAttributes, + ): void { + this.collectionDispatchDuration.record( + durationMs, + buildCollectionAttributes(attrs), + ); + } + + recordCollectionPageItems( + itemCount: number, + attrs: CollectionMetricAttributes, + ): void { + this.collectionPageItems.record( + itemCount, + buildCollectionAttributes(attrs), + ); + } + + recordCollectionTotalItems( + totalItems: number, + attrs: CollectionMetricAttributes, + ): void { + this.collectionTotalItems.record( + totalItems, + buildCollectionAttributes(attrs), + ); + } +} + +function buildCollectionAttributes( + attrs: CollectionMetricAttributes, +): Attributes { + const attributes: Attributes = { + "activitypub.collection.kind": attrs.kind, + "activitypub.collection.page": attrs.page, + "activitypub.collection.result": attrs.result, + "fedify.collection.dispatcher": attrs.dispatcher, + }; + if (attrs.statusCode != null) { + attributes["http.response.status_code"] = attrs.statusCode; + } + return attributes; } function buildActivityLifecycleAttributes( @@ -1142,6 +1290,66 @@ export function recordWebFingerHandle( getFederationMetrics(meterProvider).recordWebFingerHandle(attrs); } +/** + * Records one `activitypub.collection.request` measurement for a + * collection or collection-page request handled by Fedify. + * @since 2.3.0 + */ +export function recordCollectionRequest( + meterProvider: MeterProvider | undefined, + attrs: CollectionMetricAttributes, +): void { + getFederationMetrics(meterProvider).recordCollectionRequest(attrs); +} + +/** + * Records one `activitypub.collection.dispatch.duration` measurement for a + * collection dispatcher callback invocation. + * @since 2.3.0 + */ +export function recordCollectionDispatchDuration( + meterProvider: MeterProvider | undefined, + durationMs: number, + attrs: CollectionMetricAttributes, +): void { + getFederationMetrics(meterProvider).recordCollectionDispatchDuration( + durationMs, + attrs, + ); +} + +/** + * Records one `activitypub.collection.page.items` measurement when Fedify + * has materialized collection items in memory. + * @since 2.3.0 + */ +export function recordCollectionPageItems( + meterProvider: MeterProvider | undefined, + itemCount: number, + attrs: CollectionMetricAttributes, +): void { + getFederationMetrics(meterProvider).recordCollectionPageItems( + itemCount, + attrs, + ); +} + +/** + * Records one `activitypub.collection.total_items` measurement when a + * collection counter has already reported a total item count. + * @since 2.3.0 + */ +export function recordCollectionTotalItems( + meterProvider: MeterProvider | undefined, + totalItems: number, + attrs: CollectionMetricAttributes, +): void { + getFederationMetrics(meterProvider).recordCollectionTotalItems( + totalItems, + attrs, + ); +} + /** * Classifies a thrown value from a key or document fetch into the bounded * {@link LookupResult} taxonomy and, when an HTTP response was received, From aad994d36c68ec0c02467eb6813dc9490fc74343 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 25 May 2026 02:39:02 +0900 Subject: [PATCH 02/10] Record collection dispatch metrics Wire the collection metric instruments into built-in and custom collection request handling so operators can observe request outcomes, dispatcher latency, item counts, and reported total item counts without recording collection identifiers or cursors. https://github.com/fedify-dev/fedify/issues/316 https://github.com/fedify-dev/fedify/issues/741 Assisted-by: Codex:gpt-5.5 --- .../fedify/src/federation/handler.test.ts | 231 +++++++++ packages/fedify/src/federation/handler.ts | 458 +++++++++++++----- .../fedify/src/federation/middleware.test.ts | 40 ++ packages/fedify/src/federation/middleware.ts | 48 +- 4 files changed, 653 insertions(+), 124 deletions(-) diff --git a/packages/fedify/src/federation/handler.test.ts b/packages/fedify/src/federation/handler.test.ts index 50899b74b..0b1298b77 100644 --- a/packages/fedify/src/federation/handler.test.ts +++ b/packages/fedify/src/federation/handler.test.ts @@ -1143,6 +1143,161 @@ test("handleCollection()", async () => { assertEquals(onUnauthorizedCalled, null); }); +test("handleCollection() records OpenTelemetry collection metrics", async () => { + const [meterProvider, recorder] = createTestMeterProvider(); + const federation = createFederation({ + kv: new MemoryKvStore(), + meterProvider, + }); + const context = createRequestContext({ + federation, + data: undefined, + url: new URL("https://example.com/users/someone/followers"), + request: new Request("https://example.com/users/someone/followers", { + headers: { Accept: "application/activity+json" }, + }), + getActorUri(identifier: string) { + return new URL(`https://example.com/users/${identifier}`); + }, + }); + const dispatcher: CollectionDispatcher< + Activity, + RequestContext, + void, + void + > = (_ctx, identifier) => + identifier === "someone" + ? { + items: [ + new Create({ id: new URL("https://example.com/activities/1") }), + new Create({ id: new URL("https://example.com/activities/2") }), + new Create({ id: new URL("https://example.com/activities/3") }), + ], + } + : null; + const counter: CollectionCounter = (_ctx, identifier) => + identifier === "someone" ? 3 : null; + + const response = await handleCollection(context.request, { + context, + name: "followers", + identifier: "someone", + uriGetter(identifier) { + return new URL(`https://example.com/users/${identifier}/followers`); + }, + collectionCallbacks: { dispatcher, counter }, + meterProvider, + onNotFound: () => new Response("Not found", { status: 404 }), + onUnauthorized: () => new Response("Unauthorized", { status: 401 }), + }); + assertEquals(response.status, 200); + + const requests = recorder.getMeasurements("activitypub.collection.request"); + assertEquals(requests.length, 1); + assertEquals(requests[0].type, "counter"); + assertEquals(requests[0].value, 1); + assertEquals( + requests[0].attributes["activitypub.collection.kind"], + "followers", + ); + assertEquals(requests[0].attributes["activitypub.collection.page"], false); + assertEquals( + requests[0].attributes["fedify.collection.dispatcher"], + "built_in", + ); + assertEquals( + requests[0].attributes["activitypub.collection.result"], + "served", + ); + assertEquals(requests[0].attributes["http.response.status_code"], 200); + assertEquals( + "activitypub.collection.id" in requests[0].attributes, + false, + ); + + const durations = recorder.getMeasurements( + "activitypub.collection.dispatch.duration", + ); + assertEquals(durations.length, 1); + assertEquals(durations[0].type, "histogram"); + assert(durations[0].value >= 0); + assertEquals( + durations[0].attributes["activitypub.collection.result"], + "served", + ); + + const items = recorder.getMeasurements("activitypub.collection.page.items"); + assertEquals(items.length, 1); + assertEquals(items[0].type, "histogram"); + assertEquals(items[0].value, 3); + assertEquals(items[0].attributes["activitypub.collection.page"], false); + + const totalItems = recorder.getMeasurements( + "activitypub.collection.total_items", + ); + assertEquals(totalItems.length, 1); + assertEquals(totalItems[0].type, "histogram"); + assertEquals(totalItems[0].value, 3); +}); + +test("handleCollection() records not_found collection metrics", async () => { + const [meterProvider, recorder] = createTestMeterProvider(); + const federation = createFederation({ + kv: new MemoryKvStore(), + meterProvider, + }); + const context = createRequestContext({ + federation, + data: undefined, + url: new URL("https://example.com/users/nobody/outbox"), + request: new Request("https://example.com/users/nobody/outbox", { + headers: { Accept: "application/activity+json" }, + }), + }); + const dispatcher: CollectionDispatcher< + Activity, + RequestContext, + void, + void + > = () => null; + + const response = await handleCollection(context.request, { + context, + name: "outbox", + identifier: "nobody", + uriGetter(identifier) { + return new URL(`https://example.com/users/${identifier}/outbox`); + }, + collectionCallbacks: { dispatcher }, + meterProvider, + onNotFound: () => new Response("Not found", { status: 404 }), + onUnauthorized: () => new Response("Unauthorized", { status: 401 }), + }); + assertEquals(response.status, 404); + + const requests = recorder.getMeasurements("activitypub.collection.request"); + assertEquals(requests.length, 1); + assertEquals(requests[0].attributes["activitypub.collection.kind"], "outbox"); + assertEquals( + requests[0].attributes["activitypub.collection.result"], + "not_found", + ); + assertEquals(requests[0].attributes["http.response.status_code"], 404); + + const durations = recorder.getMeasurements( + "activitypub.collection.dispatch.duration", + ); + assertEquals(durations.length, 1); + assertEquals( + durations[0].attributes["activitypub.collection.result"], + "not_found", + ); + assertEquals( + recorder.getMeasurements("activitypub.collection.page.items").length, + 0, + ); +}); + test("handleInbox()", async () => { const activity = new Create({ id: new URL("https://example.com/activities/1"), @@ -4019,6 +4174,82 @@ test("handleCustomCollection()", async () => { assertEquals(onUnauthorizedCalled, null); }); +test("handleCustomCollection() records OpenTelemetry collection metrics", async () => { + const [meterProvider, recorder] = createTestMeterProvider(); + const federation = createFederation({ + kv: new MemoryKvStore(), + meterProvider, + }); + const context = createRequestContext({ + federation, + data: undefined, + url: new URL("https://example.com/users/someone/custom"), + request: new Request("https://example.com/users/someone/custom", { + headers: { Accept: "application/activity+json" }, + }), + }); + const dispatcher: CustomCollectionDispatcher< + Create, + string, + RequestContext, + void + > = (_ctx, values) => + values.identifier === "someone" + ? { + items: [ + new Create({ id: new URL("https://example.com/activities/1") }), + new Create({ id: new URL("https://example.com/activities/2") }), + ], + } + : null; + const counter: CustomCollectionCounter = (_ctx, values) => + values.identifier === "someone" ? 2 : null; + + const response = await handleCustomCollection(context.request, { + context, + name: "custom collection", + values: { identifier: "someone" }, + collectionCallbacks: { dispatcher, counter }, + meterProvider, + onNotFound: () => new Response("Not found", { status: 404 }), + onUnauthorized: () => new Response("Unauthorized", { status: 401 }), + }); + assertEquals(response.status, 200); + + const requests = recorder.getMeasurements("activitypub.collection.request"); + assertEquals(requests.length, 1); + assertEquals(requests[0].attributes["activitypub.collection.kind"], "custom"); + assertEquals(requests[0].attributes["activitypub.collection.page"], false); + assertEquals( + requests[0].attributes["fedify.collection.dispatcher"], + "custom", + ); + assertEquals( + requests[0].attributes["activitypub.collection.result"], + "served", + ); + assertEquals(requests[0].attributes["http.response.status_code"], 200); + + const durations = recorder.getMeasurements( + "activitypub.collection.dispatch.duration", + ); + assertEquals(durations.length, 1); + assertEquals( + durations[0].attributes["fedify.collection.dispatcher"], + "custom", + ); + + const items = recorder.getMeasurements("activitypub.collection.page.items"); + assertEquals(items.length, 1); + assertEquals(items[0].value, 2); + + const totalItems = recorder.getMeasurements( + "activitypub.collection.total_items", + ); + assertEquals(totalItems.length, 1); + assertEquals(totalItems[0].value, 2); +}); + test("handleInbox() records OpenTelemetry span events", async () => { const [tracerProvider, exporter] = createTestTracerProvider(); const [meterProvider, recorder] = createTestMeterProvider(); diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index 7d52b27e1..3c735b509 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -74,7 +74,19 @@ import type { import { routeActivity } from "./inbox.ts"; import { KvKeyCache } from "./keycache.ts"; import type { KvKey, KvStore } from "./kv.ts"; -import { getFederationMetrics, getRemoteHost } from "./metrics.ts"; +import { + type CollectionMetricAttributes, + type CollectionMetricDispatcher, + type CollectionMetricKind, + type CollectionMetricResult, + getDurationMs, + getFederationMetrics, + getRemoteHost, + recordCollectionDispatchDuration, + recordCollectionPageItems, + recordCollectionRequest, + recordCollectionTotalItems, +} from "./metrics.ts"; import type { MessageQueue } from "./mq.ts"; import { acceptsJsonLd } from "./negotiation.ts"; import { hasMalformedKnownTemporalLiteral } from "./temporal.ts"; @@ -327,10 +339,74 @@ export interface CollectionHandlerParameters< TFilter >; tracerProvider?: TracerProvider; + meterProvider?: MeterProvider; onUnauthorized(request: Request): Response | Promise; onNotFound(request: Request): Response | Promise; } +type CollectionMetricBase = Pick< + CollectionMetricAttributes, + "kind" | "page" | "dispatcher" +>; + +function getCollectionMetricKind(name: string): CollectionMetricKind { + switch (name.trim().toLowerCase().replace(/\s+/g, "_")) { + case "inbox": + case "outbox": + case "following": + case "followers": + case "liked": + case "featured": + case "featured_tags": + return name.trim().toLowerCase().replace( + /\s+/g, + "_", + ) as CollectionMetricKind; + default: + return "custom"; + } +} + +function collectionAttributes( + base: CollectionMetricBase, + result: CollectionMetricResult, + response?: Response, +): CollectionMetricAttributes { + return { + ...base, + result, + ...(response == null ? {} : { statusCode: response.status }), + }; +} + +function recordCollectionMetrics( + meterProvider: MeterProvider | undefined, + base: CollectionMetricBase, + result: CollectionMetricResult, + options: { + response?: Response; + dispatchDurationMs?: number; + itemCount?: number; + totalItems?: number; + } = {}, +): void { + const attrs = collectionAttributes(base, result, options.response); + recordCollectionRequest(meterProvider, attrs); + if (options.dispatchDurationMs != null) { + recordCollectionDispatchDuration( + meterProvider, + options.dispatchDurationMs, + attrs, + ); + } + if (options.itemCount != null) { + recordCollectionPageItems(meterProvider, options.itemCount, attrs); + } + if (options.totalItems != null) { + recordCollectionTotalItems(meterProvider, options.totalItems, attrs); + } +} + /** * Handles a collection request. * @template TItem The type of items in the collection. @@ -357,6 +433,7 @@ export async function handleCollection< context, collectionCallbacks, tracerProvider, + meterProvider, onUnauthorized, onNotFound, }: CollectionHandlerParameters, @@ -366,152 +443,209 @@ export async function handleCollection< const tracer = tracerProvider.getTracer(metadata.name, metadata.version); const url = new URL(request.url); const cursor = url.searchParams.get("cursor"); - if (collectionCallbacks == null) return await onNotFound(request); - let collection: OrderedCollection | OrderedCollectionPage; - const baseUri = uriGetter(identifier); - if (cursor == null) { - const firstCursor = await collectionCallbacks.firstCursor?.( - context, - identifier, - ); - const totalItems = filter == null - ? await collectionCallbacks.counter?.(context, identifier) - : undefined; - if (firstCursor == null) { - const itemsOrResponse = await tracer.startActiveSpan( - `activitypub.dispatch_collection ${spanName}`, + const metricBase = { + kind: getCollectionMetricKind(name), + page: cursor != null, + dispatcher: "built_in" as CollectionMetricDispatcher, + }; + let dispatchDurationMs: number | undefined; + let itemCount: number | undefined; + let totalItemCount: number | undefined; + const finish = ( + response: Response, + result: CollectionMetricResult, + ): Response => { + recordCollectionMetrics(meterProvider, metricBase, result, { + response, + dispatchDurationMs, + itemCount, + totalItems: totalItemCount, + }); + return response; + }; + try { + if (collectionCallbacks == null) { + return finish(await onNotFound(request), "not_found"); + } + let collection: OrderedCollection | OrderedCollectionPage; + const baseUri = uriGetter(identifier); + if (cursor == null) { + const firstCursor = await collectionCallbacks.firstCursor?.( + context, + identifier, + ); + const totalItems = filter == null + ? await collectionCallbacks.counter?.(context, identifier) + : undefined; + totalItemCount = totalItems == null ? undefined : Number(totalItems); + if (firstCursor == null) { + const itemsOrResponse = await tracer.startActiveSpan( + `activitypub.dispatch_collection ${spanName}`, + { + kind: SpanKind.SERVER, + attributes: { + "activitypub.collection.id": baseUri.href, + "activitypub.collection.type": OrderedCollection.typeId.href, + }, + }, + async (span) => { + if (totalItemCount != null) { + span.setAttribute( + "activitypub.collection.total_items", + totalItemCount, + ); + } + const started = performance.now(); + try { + const page = await collectionCallbacks.dispatcher( + context, + identifier, + null, + filter, + ); + dispatchDurationMs = getDurationMs(started); + if (page == null) { + span.setStatus({ code: SpanStatusCode.ERROR }); + return await onNotFound(request); + } + const { items } = page; + itemCount = items.length; + span.setAttribute("fedify.collection.items", itemCount); + return items; + } catch (e) { + if (dispatchDurationMs == null) { + dispatchDurationMs = getDurationMs(started); + } + span.setStatus({ + code: SpanStatusCode.ERROR, + message: String(e), + }); + throw e; + } finally { + span.end(); + } + }, + ); + if (itemsOrResponse instanceof Response) { + return finish(itemsOrResponse, "not_found"); + } + collection = new OrderedCollection({ + id: baseUri, + totalItems: totalItemCount ?? null, + items: filterCollectionItems(itemsOrResponse, name, filterPredicate), + }); + } else { + const lastCursor = await collectionCallbacks.lastCursor?.( + context, + identifier, + ); + const first = new URL(context.url); + first.searchParams.set("cursor", firstCursor); + let last = null; + if (lastCursor != null) { + last = new URL(context.url); + last.searchParams.set("cursor", lastCursor); + } + collection = new OrderedCollection({ + id: baseUri, + totalItems: totalItemCount ?? null, + first, + last, + }); + } + } else { + const uri = new URL(baseUri); + uri.searchParams.set("cursor", cursor); + const pageOrResponse = await tracer.startActiveSpan( + `activitypub.dispatch_collection_page ${name}`, { kind: SpanKind.SERVER, attributes: { - "activitypub.collection.id": baseUri.href, - "activitypub.collection.type": OrderedCollection.typeId.href, + "activitypub.collection.id": uri.href, + "activitypub.collection.type": OrderedCollectionPage.typeId.href, + "fedify.collection.cursor": cursor, }, }, async (span) => { - if (totalItems != null) { - span.setAttribute( - "activitypub.collection.total_items", - Number(totalItems), - ); - } + const started = performance.now(); try { const page = await collectionCallbacks.dispatcher( context, identifier, - null, + cursor, filter, ); + dispatchDurationMs = getDurationMs(started); if (page == null) { span.setStatus({ code: SpanStatusCode.ERROR }); return await onNotFound(request); } - const { items } = page; - span.setAttribute("fedify.collection.items", items.length); - return items; + itemCount = page.items.length; + span.setAttribute("fedify.collection.items", itemCount); + return page; } catch (e) { - span.setStatus({ code: SpanStatusCode.ERROR, message: String(e) }); + if (dispatchDurationMs == null) { + dispatchDurationMs = getDurationMs(started); + } + span.setStatus({ + code: SpanStatusCode.ERROR, + message: String(e), + }); throw e; } finally { span.end(); } }, ); - if (itemsOrResponse instanceof Response) return itemsOrResponse; - collection = new OrderedCollection({ - id: baseUri, - totalItems: totalItems == null ? null : Number(totalItems), - items: filterCollectionItems(itemsOrResponse, name, filterPredicate), - }); - } else { - const lastCursor = await collectionCallbacks.lastCursor?.( - context, - identifier, - ); - const first = new URL(context.url); - first.searchParams.set("cursor", firstCursor); - let last = null; - if (lastCursor != null) { - last = new URL(context.url); - last.searchParams.set("cursor", lastCursor); + if (pageOrResponse instanceof Response) { + return finish(pageOrResponse, "not_found"); + } + const { items, prevCursor, nextCursor } = pageOrResponse; + let prev = null; + if (prevCursor != null) { + prev = new URL(context.url); + prev.searchParams.set("cursor", prevCursor); + } + let next = null; + if (nextCursor != null) { + next = new URL(context.url); + next.searchParams.set("cursor", nextCursor); } - collection = new OrderedCollection({ - id: baseUri, - totalItems: totalItems == null ? null : Number(totalItems), - first, - last, + const partOf = new URL(context.url); + partOf.searchParams.delete("cursor"); + collection = new OrderedCollectionPage({ + id: uri, + prev, + next, + items: filterCollectionItems(items, name, filterPredicate), + partOf, }); } - } else { - const uri = new URL(baseUri); - uri.searchParams.set("cursor", cursor); - const pageOrResponse = await tracer.startActiveSpan( - `activitypub.dispatch_collection_page ${name}`, - { - kind: SpanKind.SERVER, - attributes: { - "activitypub.collection.id": uri.href, - "activitypub.collection.type": OrderedCollectionPage.typeId.href, - "fedify.collection.cursor": cursor, + if (collectionCallbacks.authorizePredicate != null) { + if ( + !await collectionCallbacks.authorizePredicate(context, identifier) + ) { + return finish(await onUnauthorized(request), "unauthorized"); + } + } + const jsonLd = await collection.toJsonLd(context); + return finish( + new Response(JSON.stringify(jsonLd), { + headers: { + "Content-Type": "application/activity+json", + Vary: "Accept", }, - }, - async (span) => { - try { - const page = await collectionCallbacks.dispatcher( - context, - identifier, - cursor, - filter, - ); - if (page == null) { - span.setStatus({ code: SpanStatusCode.ERROR }); - return await onNotFound(request); - } - span.setAttribute("fedify.collection.items", page.items.length); - return page; - } catch (e) { - span.setStatus({ code: SpanStatusCode.ERROR, message: String(e) }); - throw e; - } finally { - span.end(); - } - }, + }), + "served", ); - if (pageOrResponse instanceof Response) return pageOrResponse; - const { items, prevCursor, nextCursor } = pageOrResponse; - let prev = null; - if (prevCursor != null) { - prev = new URL(context.url); - prev.searchParams.set("cursor", prevCursor); - } - let next = null; - if (nextCursor != null) { - next = new URL(context.url); - next.searchParams.set("cursor", nextCursor); - } - const partOf = new URL(context.url); - partOf.searchParams.delete("cursor"); - collection = new OrderedCollectionPage({ - id: uri, - prev, - next, - items: filterCollectionItems(items, name, filterPredicate), - partOf, + } catch (e) { + recordCollectionMetrics(meterProvider, metricBase, "error", { + dispatchDurationMs, + itemCount, + totalItems: totalItemCount, }); + throw e; } - if (collectionCallbacks.authorizePredicate != null) { - if ( - !await collectionCallbacks.authorizePredicate(context, identifier) - ) { - return await onUnauthorized(request); - } - } - const jsonLd = await collection.toJsonLd(context); - return new Response(JSON.stringify(jsonLd), { - headers: { - "Content-Type": "application/activity+json", - Vary: "Accept", - }, - }); } /** @@ -1575,6 +1709,7 @@ export interface CustomCollectionHandlerParameters< TContextData >; tracerProvider?: TracerProvider; + meterProvider?: MeterProvider; } /** @@ -1614,6 +1749,7 @@ async function _handleCustomCollection< values, context, tracerProvider, + meterProvider, collectionCallbacks: callbacks, filterPredicate, }: CustomCollectionHandlerParameters< @@ -1632,6 +1768,7 @@ async function _handleCustomCollection< context, callbacks, tracerProvider, + meterProvider, Collection, CollectionPage, filterPredicate, @@ -1677,6 +1814,7 @@ async function _handleOrderedCollection< values, context, tracerProvider, + meterProvider, collectionCallbacks: callbacks, filterPredicate, }: CustomCollectionHandlerParameters< @@ -1695,6 +1833,7 @@ async function _handleOrderedCollection< context, callbacks, tracerProvider, + meterProvider, OrderedCollection, OrderedCollectionPage, filterPredicate, @@ -1775,6 +1914,7 @@ class CustomCollectionHandler< TContextData >, private readonly tracerProvider: TracerProvider = trace.getTracerProvider(), + private readonly meterProvider: MeterProvider | undefined, private readonly Collection: ConstructorWithTypeId, private readonly CollectionPage: ConstructorWithTypeId, private readonly filterPredicate?: (item: TItem) => boolean, @@ -1859,11 +1999,19 @@ class CustomCollectionHandler< this.context, this.values, ); + const totalItems = await this.totalItems; + if (totalItems != null) { + recordCollectionTotalItems( + this.meterProvider, + totalItems, + this.metricAttributes(false, "served"), + ); + } return { id: this.#id, first: this.appendToUrl(firstCursor), last: this.appendToUrl(lastCursor), - totalItems: await this.totalItems, + totalItems, }; } @@ -1928,14 +2076,37 @@ class CustomCollectionHandler< cursor = null, }) => async (span: Span): Promise> => { + const pageMetricBase = this.metricBase(cursor !== null); + const started = performance.now(); try { if (totalItems !== null) { span.setAttribute(this.ATTRS.TOTAL_ITEMS, totalItems); } const page = await this.dispatch(cursor); + const durationMs = getDurationMs(started); span.setAttribute(this.ATTRS.ITEMS, page.items.length); + const attrs = collectionAttributes(pageMetricBase, "served"); + recordCollectionDispatchDuration( + this.meterProvider, + durationMs, + attrs, + ); + recordCollectionPageItems( + this.meterProvider, + page.items.length, + attrs, + ); + if (totalItems !== null) { + recordCollectionTotalItems(this.meterProvider, totalItems, attrs); + } return page; } catch (e) { + const result = e instanceof ItemsNotFoundError ? "not_found" : "error"; + recordCollectionDispatchDuration( + this.meterProvider, + getDurationMs(started), + this.metricAttributes(cursor !== null, result), + ); const message = e instanceof Error ? e.message : String(e); span.setStatus({ code: SpanStatusCode.ERROR, message }); throw e; @@ -1968,6 +2139,17 @@ class CustomCollectionHandler< return filterCollectionItems(items, this.name, this.filterPredicate); } + metricBase(page: boolean): CollectionMetricBase { + return { kind: "custom", page, dispatcher: "custom" }; + } + + metricAttributes( + page: boolean, + result: CollectionMetricResult, + ): CollectionMetricAttributes { + return collectionAttributes(this.metricBase(page), result); + } + /** * Appends a cursor to the URL if it exists. * @param cursor The cursor to append, or null/undefined. @@ -2051,16 +2233,46 @@ function exceptWrapper( handler: (request: Request, handleParams: TParams) => Promise, ): (...args: Parameters) => Promise { return async (request, handlerParams): Promise => { + const page = new URL(request.url).searchParams.get("cursor") != null; + const meterProvider = + (handlerParams as ErrorHandlers & { meterProvider?: MeterProvider }) + .meterProvider; + const metricBase: CollectionMetricBase = { + kind: "custom", + page, + dispatcher: "custom", + }; try { - return await handler(request, handlerParams); + const response = await handler(request, handlerParams); + recordCollectionRequest( + meterProvider, + collectionAttributes(metricBase, "served", response), + ); + return response; } catch (error) { const { onNotFound, onUnauthorized } = handlerParams; switch (error?.constructor) { - case ItemsNotFoundError: - return await onNotFound(request); - case UnauthorizedError: - return await onUnauthorized(request); + case ItemsNotFoundError: { + const response = await onNotFound(request); + recordCollectionRequest( + meterProvider, + collectionAttributes(metricBase, "not_found", response), + ); + return response; + } + case UnauthorizedError: { + const response = await onUnauthorized(request); + recordCollectionRequest( + meterProvider, + collectionAttributes(metricBase, "unauthorized", response), + ); + return response; + } default: + recordCollectionRequest( + meterProvider, + collectionAttributes(metricBase, "error"), + ); throw error; } } diff --git a/packages/fedify/src/federation/middleware.test.ts b/packages/fedify/src/federation/middleware.test.ts index 4b30d31f2..7d10e6304 100644 --- a/packages/fedify/src/federation/middleware.test.ts +++ b/packages/fedify/src/federation/middleware.test.ts @@ -1676,6 +1676,46 @@ test("Federation.fetch() records HTTP server request metrics", async (t) => { }, ); + await t.step( + "records collection metrics for not_acceptable collection requests", + async () => { + const { federation, recorder } = createTestContext(); + const response = await federation.fetch( + new Request("https://example.com/users/alice/followers", { + method: "GET", + headers: { "Accept": "text/html" }, + }), + { contextData: undefined }, + ); + assertEquals(response.status, 406); + + const requests = recorder.getMeasurements( + "activitypub.collection.request", + ); + assertEquals(requests.length, 1); + assertEquals( + requests[0].attributes["activitypub.collection.kind"], + "followers", + ); + assertEquals( + requests[0].attributes["activitypub.collection.page"], + false, + ); + assertEquals( + requests[0].attributes["fedify.collection.dispatcher"], + "built_in", + ); + assertEquals( + requests[0].attributes["activitypub.collection.result"], + "not_acceptable", + ); + assertEquals( + requests[0].attributes["http.response.status_code"], + 406, + ); + }, + ); + await t.step( "records thrown errors after classification with the matched endpoint", async () => { diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index 9e320d62b..fb31b9a07 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -118,6 +118,8 @@ import { routeActivity } from "./inbox.ts"; import { KvKeyCache } from "./keycache.ts"; import type { KvKey, KvStore } from "./kv.ts"; import { + type CollectionMetricDispatcher, + type CollectionMetricKind, getDurationMs, getFederationMetrics, getRemoteHost, @@ -125,6 +127,7 @@ import { isAbortError, type QueueTaskCommonAttributes, type QueueTaskResult, + recordCollectionRequest, recordFanoutRecipients, recordInboxActivity, recordOutboxActivity, @@ -1924,7 +1927,17 @@ export class FederationImpl // Routes that require JSON-LD Accepts header: if (request.method !== "POST" && !acceptsJsonLd(request)) { metricState.endpoint = "not_acceptable"; - return await onNotAcceptable(request); + const response = await onNotAcceptable(request); + const collectionRoute = getCollectionMetricRoute(routeName); + if (collectionRoute != null) { + recordCollectionRequest(this._meterProvider, { + ...collectionRoute, + page: url.searchParams.get("cursor") != null, + result: "not_acceptable", + statusCode: response.status, + }); + } + return response; } switch (routeName) { case "actor": @@ -1991,6 +2004,7 @@ export class FederationImpl context, collectionCallbacks: this.outboxCallbacks, tracerProvider: this.tracerProvider, + meterProvider: this._meterProvider, onUnauthorized, onNotFound, }); @@ -2003,6 +2017,7 @@ export class FederationImpl context, collectionCallbacks: this.inboxCallbacks, tracerProvider: this.tracerProvider, + meterProvider: this._meterProvider, onUnauthorized, onNotFound, }); @@ -2060,6 +2075,7 @@ export class FederationImpl context, collectionCallbacks: this.followingCallbacks, tracerProvider: this.tracerProvider, + meterProvider: this._meterProvider, onUnauthorized, onNotFound, }); @@ -2093,6 +2109,7 @@ export class FederationImpl : undefined, collectionCallbacks: this.followersCallbacks, tracerProvider: this.tracerProvider, + meterProvider: this._meterProvider, onUnauthorized, onNotFound, }); @@ -2105,6 +2122,7 @@ export class FederationImpl context, collectionCallbacks: this.likedCallbacks, tracerProvider: this.tracerProvider, + meterProvider: this._meterProvider, onUnauthorized, onNotFound, }); @@ -2116,6 +2134,7 @@ export class FederationImpl context, collectionCallbacks: this.featuredCallbacks, tracerProvider: this.tracerProvider, + meterProvider: this._meterProvider, onUnauthorized, onNotFound, }); @@ -2127,6 +2146,7 @@ export class FederationImpl context, collectionCallbacks: this.featuredTagsCallbacks, tracerProvider: this.tracerProvider, + meterProvider: this._meterProvider, onUnauthorized, onNotFound, }); @@ -2144,6 +2164,7 @@ export class FederationImpl values: route.values, collectionCallbacks: callbacks, tracerProvider: this.tracerProvider, + meterProvider: this._meterProvider, onUnauthorized, onNotFound, }); @@ -2162,6 +2183,7 @@ export class FederationImpl values: route.values, collectionCallbacks: callbacks, tracerProvider: this.tracerProvider, + meterProvider: this._meterProvider, onUnauthorized, onNotFound, }); @@ -2236,6 +2258,30 @@ function getEndpointCategory(routeName: string): FedifyEndpoint { } } +function getCollectionMetricRoute(routeName: string): + | { + kind: CollectionMetricKind; + dispatcher: CollectionMetricDispatcher; + } + | undefined { + switch (routeName) { + case "inbox": + case "outbox": + case "following": + case "followers": + case "liked": + case "featured": + return { kind: routeName, dispatcher: "built_in" }; + case "featuredTags": + return { kind: "featured_tags", dispatcher: "built_in" }; + case "collection": + case "orderedCollection": + return { kind: "custom", dispatcher: "custom" }; + default: + return undefined; + } +} + interface ContextOptions { url: URL; federation: FederationImpl; From b843c6a0bbf911038706ae3ba4989d51967a9fec Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 25 May 2026 02:43:04 +0900 Subject: [PATCH 03/10] Document collection metrics Document the new ActivityPub collection metrics and changelog entry so operators can discover the request, dispatch latency, item count, and total item count measurements and their bounded attributes. https://github.com/fedify-dev/fedify/issues/316 https://github.com/fedify-dev/fedify/issues/741 https://github.com/fedify-dev/fedify/pull/777 Assisted-by: Codex:gpt-5.5 --- CHANGES.md | 21 ++++ docs/manual/opentelemetry.md | 196 ++++++++++++++++++++++------------- 2 files changed, 147 insertions(+), 70 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 9398d5286..2bc850d72 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -66,6 +66,25 @@ To be released. deliberately exclude raw URLs, query strings, and identifier values to keep cardinality bounded. [[#316], [#736], [#757]] + - Added OpenTelemetry metrics for ActivityPub collection requests handled + by `Federation.fetch()` and custom collection handlers: + + - `activitypub.collection.request` (counter) + - `activitypub.collection.dispatch.duration` (histogram) + - `activitypub.collection.page.items` (histogram) + - `activitypub.collection.total_items` (histogram) + + The metrics expose bounded collection dimensions: + `activitypub.collection.kind`, `activitypub.collection.page`, + `activitypub.collection.result`, `fedify.collection.dispatcher`, and + optional `http.response.status_code`. Built-in collections are classified + as `inbox`, `outbox`, `following`, `followers`, `liked`, `featured`, or + `featured_tags`; application-defined collection routes are collapsed into + `custom`. Collection IDs, cursors, custom route names, actor identifiers, + and full URLs are deliberately excluded so dashboards can aggregate + collection rate, latency, item counts, and `totalItems` values without + attacker-controlled cardinality. [[#316], [#741], [#777]] + - Added OpenTelemetry queue task metrics covering Fedify's enqueue and worker boundaries for inbox, outbox, and fanout work: @@ -208,6 +227,7 @@ To be released. [#738]: https://github.com/fedify-dev/fedify/issues/738 [#739]: https://github.com/fedify-dev/fedify/issues/739 [#740]: https://github.com/fedify-dev/fedify/issues/740 +[#741]: https://github.com/fedify-dev/fedify/issues/741 [#742]: https://github.com/fedify-dev/fedify/issues/742 [#748]: https://github.com/fedify-dev/fedify/pull/748 [#752]: https://github.com/fedify-dev/fedify/issues/752 @@ -220,6 +240,7 @@ To be released. [#770]: https://github.com/fedify-dev/fedify/pull/770 [#771]: https://github.com/fedify-dev/fedify/pull/771 [#772]: https://github.com/fedify-dev/fedify/pull/772 +[#777]: https://github.com/fedify-dev/fedify/pull/777 ### @fedify/fixture diff --git a/docs/manual/opentelemetry.md b/docs/manual/opentelemetry.md index 34dc02489..7f6bb3051 100644 --- a/docs/manual/opentelemetry.md +++ b/docs/manual/opentelemetry.md @@ -184,12 +184,11 @@ const federation = createFederation({ > so existing test code that asserts identity on a user-supplied > factory's output continues to work. The other metrics (delivery, > inbox, outbox, fanout, queue, HTTP server, signature verification, -> signature key fetch, public key lookup, and `lookupObject` actor -> classification) follow the standard “fall back to the global -> [`MeterProvider`]” behavior described above. Calling -> `lookupObject()` directly from `@fedify/vocab` (without going through -> a `Context`) still requires an explicit -> `LookupObjectOptions.meterProvider` to emit +> signature key fetch, public key lookup, collection request, and +> `lookupObject` actor classification) follow the standard “fall back to the +> global [`MeterProvider`]” behavior described above. Calling `lookupObject()` +> directly from `@fedify/vocab` (without going through a `Context`) still +> requires an explicit `LookupObjectOptions.meterProvider` to emit > `activitypub.object.lookup`; `Context.lookupObject()` threads the > Federation's meter provider through automatically. @@ -325,6 +324,10 @@ Fedify records the following OpenTelemetry metrics: | `activitypub.inbox.processing_duration` | Histogram | `ms` | Measures inbox listener processing duration. | | `activitypub.outbox.activity` | Counter | `{activity}` | Classifies outbound activities by lifecycle outcome. | | `activitypub.fanout.recipients` | Histogram | `{recipient}` | Records the recipient inbox count produced by a single fanout enqueue. | +| `activitypub.collection.request` | Counter | `{request}` | Counts ActivityPub collection and collection-page requests. | +| `activitypub.collection.dispatch.duration` | Histogram | `ms` | Measures collection dispatcher callback duration. | +| `activitypub.collection.page.items` | Histogram | `{item}` | Records item counts materialized for collection and collection-page responses. | +| `activitypub.collection.total_items` | Histogram | `{item}` | Records total item counts reported by collection counters. | | `activitypub.signature.verification_failure` | Counter | `{failure}` | Counts failed signature verification for inbox requests. | | `activitypub.signature.verification.duration` | Histogram | `ms` | Measures signature verification duration across HTTP, Linked Data, and Object Integrity Proofs. | | `activitypub.signature.key_fetch.duration` | Histogram | `ms` | Measures public key lookup duration during signature verification. | @@ -423,6 +426,50 @@ Fedify records the following OpenTelemetry metrics: `fanout: "force"` always enqueues a fanout task, and `fanout: "skip"` bypasses fanout regardless of recipient count. +`activitypub.collection.request`, +`activitypub.collection.dispatch.duration`, +`activitypub.collection.page.items`, and +`activitypub.collection.total_items` +: `activitypub.collection.kind`, `activitypub.collection.page`, + `activitypub.collection.result`, and `fedify.collection.dispatcher` + are always present. `http.response.status_code` is recorded when + Fedify produced a `Response`. + + `activitypub.collection.kind` is one of `inbox`, `outbox`, + `following`, `followers`, `liked`, `featured`, `featured_tags`, or + `custom`. Application-defined collection routes are deliberately + collapsed into `custom`; custom route names, URI parameters, actor + identifiers, collection IDs, cursors, and full URLs are excluded + from these metrics to keep cardinality bounded. + + `activitypub.collection.page` is `true` when the request targets a + cursor page and `false` for the collection object itself. + `fedify.collection.dispatcher` is `built_in` for Fedify's built-in + ActivityPub collection routes and `custom` for application-defined + custom collection routes. `activitypub.collection.result` is one + of: + + - `served`: Fedify returned a collection response. + - `not_found`: the dispatcher was missing or reported no items for + the requested collection or page. + - `not_acceptable`: the request matched a collection route but did + not accept JSON-LD. + - `unauthorized`: the collection authorization predicate rejected + the request. + - `error`: the handler threw before producing one of the terminal + responses above. + + The request counter is emitted once per handled collection request. + The dispatch-duration histogram is emitted once per collection + dispatcher callback invocation and measures only the callback's + execution time, not JSON-LD serialization or error-response + construction. The page-items histogram is emitted when Fedify has + materialized an in-memory `items` array for a collection or page; + collection objects that only point to first/last page cursors do not + emit it. The total-items histogram is emitted when a collection + counter callback reported a value that Fedify already needed while + handling the request. + `activitypub.inbox.processing_duration` : `activitypub.activity.type`. @@ -809,6 +856,11 @@ for a sampled trace; the metrics expose the stable endpoint category and route template so that aggregate request rate, latency, and status-code error rate remain meaningful even when traces are sampled. +The collection metrics similarly expose only bounded collection dimensions: +collection kind, whether the request is for a cursor page, dispatcher family, +terminal result, and optional status code. Use the collection dispatch spans +when you need trace-level collection IDs, cursor values, or custom route names. + The `fedify.endpoint` attribute is drawn from a fixed enumeration: `webfinger`, `nodeinfo`, `actor`, `inbox`, `shared_inbox`, `outbox`, `object`, `following`, `followers`, `liked`, `featured`, `featured_tags`, @@ -829,70 +881,74 @@ for ActivityPub as of November 2024. However, Fedify provides a set of semantic [attributes] for ActivityPub. The following table shows the semantic attributes for ActivityPub: -| Attribute | Type | Description | Example | -| ---------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- | -| `activitypub.activity.id` | string | The URI of the activity object. | `"https://example.com/activity/1"` | -| `activitypub.activity.type` | string[] | The qualified URI(s) of the activity type(s). | `["https://www.w3.org/ns/activitystreams#Create"]` | -| `activitypub.activity.to` | string[] | The URI(s) of the recipient collections/actors of the activity. | `["https://example.com/1/followers/2"]` | -| `activitypub.activity.cc` | string[] | The URI(s) of the carbon-copied recipient collections/actors of the activity. | `["https://www.w3.org/ns/activitystreams#Public"]` | -| `activitypub.activity.bto` | string[] | The URI(s) of the blind recipient collections/actors of the activity. | `["https://example.com/1/followers/2"]` | -| `activitypub.activity.bcc` | string[] | The URI(s) of the blind carbon-copied recipient collections/actors of the activity. | `["https://www.w3.org/ns/activitystreams#Public"]` | -| `activitypub.activity.retries` | int | The ordinal number of activity resending attempt (if and only if it's retried). | `3` | -| `activitypub.delivery.attempt` | int | The zero-based delivery attempt number for a queued outgoing activity. | `0` | -| `activitypub.delivery.permanent_failure` | boolean | Whether an outgoing delivery failure will be abandoned instead of retried. | `true` | -| `activitypub.processing.result` | string | Lifecycle outcome of an inbox or outbox activity: `queued`, `processed`, `retried`, `rejected`, or `abandoned`. | `"retried"` | -| `activitypub.actor.discovery.result` | string | Terminal outcome of `getActorHandle()`: `resolved`, `not_found`, or `error`. | `"resolved"` | -| `activitypub.actor.id` | string | The URI of the actor object. | `"https://example.com/actor/1"` | -| `activitypub.actor.key.cached` | boolean | Whether the actor's public keys are cached. | `true` | -| `activitypub.actor.type` | string[] | The qualified URI(s) of the actor type(s). | `["https://www.w3.org/ns/activitystreams#Person"]` | -| `activitypub.key.id` | string | The URI of the cryptographic key being verified. | `"https://example.com/actor/1#main-key"` | -| `activitypub.key_ownership.method` | string | The method used to verify key ownership (`owner_id` or `actor_fetch`). | `"actor_fetch"` | -| `activitypub.key_ownership.verified` | boolean | Whether the key ownership was successfully verified. | `true` | -| `activitypub.collection.id` | string | The URI of the collection object. | `"https://example.com/collection/1"` | -| `activitypub.collection.type` | string[] | The qualified URI(s) of the collection type(s). | `["https://www.w3.org/ns/activitystreams#OrderedCollection"]` | -| `activitypub.collection.total_items` | int | The total number of items in the collection. | `42` | -| `activitypub.object.id` | string | The URI of the object or the object enclosed by the activity. | `"https://example.com/object/1"` | -| `activitypub.object.type` | string[] | The qualified URI(s) of the object type(s). | `["https://www.w3.org/ns/activitystreams#Note"]` | -| `activitypub.object.in_reply_to` | string[] | The URI(s) of the original object to which the object reply. | `["https://example.com/object/1"]` | -| `activitypub.inboxes` | int | The number of inboxes the activity is sent to. | `12` | -| `activitypub.remote.host` | string | The hostname of the remote ActivityPub server. | `"example.com"` | -| `activitypub.shared_inbox` | boolean | Whether the activity is sent to the shared inbox. | `true` | -| `docloader.context_url` | string | The URL of the JSON-LD context document (if provided via Link header). | `"https://www.w3.org/ns/activitystreams"` | -| `docloader.document_url` | string | The final URL of the fetched document (after following redirects). | `"https://example.com/object/1"` | -| `fedify.actor.identifier` | string | The identifier of the actor. | `"1"` | -| `fedify.endpoint` | string | The bounded endpoint category that classified an inbound HTTP request handled by `Federation.fetch()`. | `"actor"` | -| `fedify.route.template` | string | The matched URI Template, with parameter names (not values). | `"/users/{identifier}"` | -| `fedify.inbox.recipient` | string | The identifier of the inbox recipient. | `"1"` | -| `fedify.object.type` | string | The URI of the object type. | `"https://www.w3.org/ns/activitystreams#Note"` | -| `fedify.object.values.{parameter}` | string[] | The argument values of the object dispatcher. | `["1", "2"]` | -| `fedify.collection.cursor` | string | The cursor of the collection. | `"eyJpZCI6IjEiLCJ0eXBlIjoiT3JkZXJlZENvbGxlY3Rpb24ifQ=="` | -| `fedify.collection.items` | number | The number of items in the collection page. It can be less than the total items. | `10` | -| `fedify.queue.role` | string | The Fedify queue role for the task: `inbox`, `outbox`, or `fanout`. | `"outbox"` | -| `fedify.queue.backend` | string | The queue implementation's constructor name (best-effort backend identifier). | `"RedisMessageQueue"` | -| `fedify.queue.native_retrial` | boolean | Whether the queue backend declares `nativeRetrial`, meaning Fedify defers retry handling to the backend. | `true` | -| `fedify.queue.task.attempt` | int | The zero-based attempt number recorded on `fedify.queue.task.enqueued`; non-zero for retry re-enqueues. | `1` | -| `fedify.queue.task.result` | string | The terminal outcome of queue task processing: `completed`, `failed`, or `aborted`. | `"failed"` | -| `http.redirect.url` | string | The redirect URL when a document fetch results in a redirect. | `"https://example.com/new-location"` | -| `http.response.status_code` | int | The HTTP response status code. | `200` | -| `http_signatures.signature` | string | The signature of the HTTP request in hexadecimal. | `"73a74c990beabe6e59cc68f9c6db7811b59cbb22fd12dcffb3565b651540efe9"` | -| `http_signatures.algorithm` | string | The algorithm of the HTTP request signature. | `"rsa-sha256"` | -| `http_signatures.key_id` | string | The public key ID of the HTTP request signature. | `"https://example.com/actor/1#main-key"` | -| `http_signatures.verified` | boolean | Whether the HTTP request signature was verified successfully. | `false` | -| `http_signatures.failure_reason` | string | Why HTTP signature verification failed (`noSignature`, `invalidSignature`, or `keyFetchError`). | `"keyFetchError"` | -| `http_signatures.key_fetch_status` | int | The HTTP status code from a failed signing-key fetch, when available. | `410` | -| `http_signatures.key_fetch_error` | string | The error type from a non-HTTP signing-key fetch failure, when available. | `"TypeError"` | -| `http_signatures.digest.{algorithm}` | string | The digest of the HTTP request body in hexadecimal. The `{algorithm}` is the digest algorithm (e.g., `sha`, `sha-256`). | `"d41d8cd98f00b204e9800998ecf8427e"` | -| `ld_signatures.key_id` | string | The public key ID of the Linked Data signature. | `"https://example.com/actor/1#main-key"` | -| `ld_signatures.signature` | string | The signature of the Linked Data in hexadecimal. | `"73a74c990beabe6e59cc68f9c6db7811b59cbb22fd12dcffb3565b651540efe9"` | -| `ld_signatures.type` | string | The algorithm of the Linked Data signature. | `"RsaSignature2017"` | -| `object_integrity_proofs.cryptosuite` | string | The cryptographic suite of the object integrity proof. | `"eddsa-jcs-2022"` | -| `object_integrity_proofs.key_id` | string | The public key ID of the object integrity proof. | `"https://example.com/actor/1#main-key"` | -| `object_integrity_proofs.signature` | string | The integrity proof of the object in hexadecimal. | `"73a74c990beabe6e59cc68f9c6db7811b59cbb22fd12dcffb3565b651540efe9"` | -| `url.full` | string | The full URL being fetched by the document loader. | `"https://example.com/actor/1"` | -| `webfinger.handle.result` | string | Terminal outcome of an incoming WebFinger request: `resolved`, `invalid`, `not_found`, `tombstoned`, or `error`. | `"resolved"` | -| `webfinger.lookup.result` | string | Terminal outcome of an outgoing WebFinger lookup: `found`, `not_found`, `invalid`, `network_error`, or `error`. | `"found"` | -| `webfinger.resource` | string | The queried resource URI. | `"acct:fedify@hollo.social"` | -| `webfinger.resource.scheme` | string | The scheme of the queried resource URI. Metric attribute is bucketed to `acct`, `http`, `https`, `mailto`, or `other`. | `"acct"` | +| Attribute | Type | Description | Example | +| ---------------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| `activitypub.activity.id` | string | The URI of the activity object. | `"https://example.com/activity/1"` | +| `activitypub.activity.type` | string[] | The qualified URI(s) of the activity type(s). | `["https://www.w3.org/ns/activitystreams#Create"]` | +| `activitypub.activity.to` | string[] | The URI(s) of the recipient collections/actors of the activity. | `["https://example.com/1/followers/2"]` | +| `activitypub.activity.cc` | string[] | The URI(s) of the carbon-copied recipient collections/actors of the activity. | `["https://www.w3.org/ns/activitystreams#Public"]` | +| `activitypub.activity.bto` | string[] | The URI(s) of the blind recipient collections/actors of the activity. | `["https://example.com/1/followers/2"]` | +| `activitypub.activity.bcc` | string[] | The URI(s) of the blind carbon-copied recipient collections/actors of the activity. | `["https://www.w3.org/ns/activitystreams#Public"]` | +| `activitypub.activity.retries` | int | The ordinal number of activity resending attempt (if and only if it's retried). | `3` | +| `activitypub.delivery.attempt` | int | The zero-based delivery attempt number for a queued outgoing activity. | `0` | +| `activitypub.delivery.permanent_failure` | boolean | Whether an outgoing delivery failure will be abandoned instead of retried. | `true` | +| `activitypub.processing.result` | string | Lifecycle outcome of an inbox or outbox activity: `queued`, `processed`, `retried`, `rejected`, or `abandoned`. | `"retried"` | +| `activitypub.actor.discovery.result` | string | Terminal outcome of `getActorHandle()`: `resolved`, `not_found`, or `error`. | `"resolved"` | +| `activitypub.actor.id` | string | The URI of the actor object. | `"https://example.com/actor/1"` | +| `activitypub.actor.key.cached` | boolean | Whether the actor's public keys are cached. | `true` | +| `activitypub.actor.type` | string[] | The qualified URI(s) of the actor type(s). | `["https://www.w3.org/ns/activitystreams#Person"]` | +| `activitypub.key.id` | string | The URI of the cryptographic key being verified. | `"https://example.com/actor/1#main-key"` | +| `activitypub.key_ownership.method` | string | The method used to verify key ownership (`owner_id` or `actor_fetch`). | `"actor_fetch"` | +| `activitypub.key_ownership.verified` | boolean | Whether the key ownership was successfully verified. | `true` | +| `activitypub.collection.id` | string | The URI of the collection object. | `"https://example.com/collection/1"` | +| `activitypub.collection.kind` | string | The bounded collection kind: `inbox`, `outbox`, `following`, `followers`, `liked`, `featured`, `featured_tags`, or `custom`. | `"followers"` | +| `activitypub.collection.page` | boolean | Whether the collection request targets a cursor page rather than the collection object. | `false` | +| `activitypub.collection.result` | string | Terminal collection request outcome: `served`, `not_found`, `not_acceptable`, `unauthorized`, or `error`. | `"served"` | +| `activitypub.collection.type` | string[] | The qualified URI(s) of the collection type(s). | `["https://www.w3.org/ns/activitystreams#OrderedCollection"]` | +| `activitypub.collection.total_items` | int | The total number of items in the collection. | `42` | +| `activitypub.object.id` | string | The URI of the object or the object enclosed by the activity. | `"https://example.com/object/1"` | +| `activitypub.object.type` | string[] | The qualified URI(s) of the object type(s). | `["https://www.w3.org/ns/activitystreams#Note"]` | +| `activitypub.object.in_reply_to` | string[] | The URI(s) of the original object to which the object reply. | `["https://example.com/object/1"]` | +| `activitypub.inboxes` | int | The number of inboxes the activity is sent to. | `12` | +| `activitypub.remote.host` | string | The hostname of the remote ActivityPub server. | `"example.com"` | +| `activitypub.shared_inbox` | boolean | Whether the activity is sent to the shared inbox. | `true` | +| `docloader.context_url` | string | The URL of the JSON-LD context document (if provided via Link header). | `"https://www.w3.org/ns/activitystreams"` | +| `docloader.document_url` | string | The final URL of the fetched document (after following redirects). | `"https://example.com/object/1"` | +| `fedify.actor.identifier` | string | The identifier of the actor. | `"1"` | +| `fedify.endpoint` | string | The bounded endpoint category that classified an inbound HTTP request handled by `Federation.fetch()`. | `"actor"` | +| `fedify.route.template` | string | The matched URI Template, with parameter names (not values). | `"/users/{identifier}"` | +| `fedify.inbox.recipient` | string | The identifier of the inbox recipient. | `"1"` | +| `fedify.object.type` | string | The URI of the object type. | `"https://www.w3.org/ns/activitystreams#Note"` | +| `fedify.object.values.{parameter}` | string[] | The argument values of the object dispatcher. | `["1", "2"]` | +| `fedify.collection.dispatcher` | string | The collection dispatcher family: `built_in` or `custom`. | `"built_in"` | +| `fedify.collection.cursor` | string | The cursor of the collection. | `"eyJpZCI6IjEiLCJ0eXBlIjoiT3JkZXJlZENvbGxlY3Rpb24ifQ=="` | +| `fedify.collection.items` | number | The number of materialized items in the collection response or page. It can be less than the total items. | `10` | +| `fedify.queue.role` | string | The Fedify queue role for the task: `inbox`, `outbox`, or `fanout`. | `"outbox"` | +| `fedify.queue.backend` | string | The queue implementation's constructor name (best-effort backend identifier). | `"RedisMessageQueue"` | +| `fedify.queue.native_retrial` | boolean | Whether the queue backend declares `nativeRetrial`, meaning Fedify defers retry handling to the backend. | `true` | +| `fedify.queue.task.attempt` | int | The zero-based attempt number recorded on `fedify.queue.task.enqueued`; non-zero for retry re-enqueues. | `1` | +| `fedify.queue.task.result` | string | The terminal outcome of queue task processing: `completed`, `failed`, or `aborted`. | `"failed"` | +| `http.redirect.url` | string | The redirect URL when a document fetch results in a redirect. | `"https://example.com/new-location"` | +| `http.response.status_code` | int | The HTTP response status code. | `200` | +| `http_signatures.signature` | string | The signature of the HTTP request in hexadecimal. | `"73a74c990beabe6e59cc68f9c6db7811b59cbb22fd12dcffb3565b651540efe9"` | +| `http_signatures.algorithm` | string | The algorithm of the HTTP request signature. | `"rsa-sha256"` | +| `http_signatures.key_id` | string | The public key ID of the HTTP request signature. | `"https://example.com/actor/1#main-key"` | +| `http_signatures.verified` | boolean | Whether the HTTP request signature was verified successfully. | `false` | +| `http_signatures.failure_reason` | string | Why HTTP signature verification failed (`noSignature`, `invalidSignature`, or `keyFetchError`). | `"keyFetchError"` | +| `http_signatures.key_fetch_status` | int | The HTTP status code from a failed signing-key fetch, when available. | `410` | +| `http_signatures.key_fetch_error` | string | The error type from a non-HTTP signing-key fetch failure, when available. | `"TypeError"` | +| `http_signatures.digest.{algorithm}` | string | The digest of the HTTP request body in hexadecimal. The `{algorithm}` is the digest algorithm (e.g., `sha`, `sha-256`). | `"d41d8cd98f00b204e9800998ecf8427e"` | +| `ld_signatures.key_id` | string | The public key ID of the Linked Data signature. | `"https://example.com/actor/1#main-key"` | +| `ld_signatures.signature` | string | The signature of the Linked Data in hexadecimal. | `"73a74c990beabe6e59cc68f9c6db7811b59cbb22fd12dcffb3565b651540efe9"` | +| `ld_signatures.type` | string | The algorithm of the Linked Data signature. | `"RsaSignature2017"` | +| `object_integrity_proofs.cryptosuite` | string | The cryptographic suite of the object integrity proof. | `"eddsa-jcs-2022"` | +| `object_integrity_proofs.key_id` | string | The public key ID of the object integrity proof. | `"https://example.com/actor/1#main-key"` | +| `object_integrity_proofs.signature` | string | The integrity proof of the object in hexadecimal. | `"73a74c990beabe6e59cc68f9c6db7811b59cbb22fd12dcffb3565b651540efe9"` | +| `url.full` | string | The full URL being fetched by the document loader. | `"https://example.com/actor/1"` | +| `webfinger.handle.result` | string | Terminal outcome of an incoming WebFinger request: `resolved`, `invalid`, `not_found`, `tombstoned`, or `error`. | `"resolved"` | +| `webfinger.lookup.result` | string | Terminal outcome of an outgoing WebFinger lookup: `found`, `not_found`, `invalid`, `network_error`, or `error`. | `"found"` | +| `webfinger.resource` | string | The queried resource URI. | `"acct:fedify@hollo.social"` | +| `webfinger.resource.scheme` | string | The scheme of the queried resource URI. Metric attribute is bucketed to `acct`, `http`, `https`, `mailto`, or `other`. | `"acct"` | [attributes]: https://opentelemetry.io/docs/specs/otel/common/#attribute [OpenTelemetry Semantic Conventions]: https://opentelemetry.io/docs/specs/semconv/ From 3fba894f2ddb1cba39e9162fc8db0328ae887c31 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 25 May 2026 09:30:12 +0900 Subject: [PATCH 04/10] Defer custom collection metrics Record custom collection dispatcher measurements only after the handler knows the final request result, so serialization failures classify the duration, item count, and total item count histograms as errors instead of served responses. Also normalize built-in collection metric names once through a shared set. https://github.com/fedify-dev/fedify/pull/777#discussion_r3295393016 https://github.com/fedify-dev/fedify/pull/777#discussion_r3295396955 Assisted-by: Codex:gpt-5.5 --- .../fedify/src/federation/handler.test.ts | 80 +++++++++++ packages/fedify/src/federation/handler.ts | 134 ++++++++++++------ 2 files changed, 167 insertions(+), 47 deletions(-) diff --git a/packages/fedify/src/federation/handler.test.ts b/packages/fedify/src/federation/handler.test.ts index 0b1298b77..1a53ad40f 100644 --- a/packages/fedify/src/federation/handler.test.ts +++ b/packages/fedify/src/federation/handler.test.ts @@ -4250,6 +4250,86 @@ test("handleCustomCollection() records OpenTelemetry collection metrics", async assertEquals(totalItems[0].value, 2); }); +test("handleCustomCollection() classifies deferred collection metrics as error", async () => { + const [meterProvider, recorder] = createTestMeterProvider(); + const federation = createFederation({ + kv: new MemoryKvStore(), + meterProvider, + }); + const context = createRequestContext({ + federation, + data: undefined, + url: new URL("https://example.com/users/someone/custom"), + request: new Request("https://example.com/users/someone/custom", { + headers: { Accept: "application/activity+json" }, + }), + }); + const brokenActivity = new Create({ + id: new URL("https://example.com/activities/1"), + }); + globalThis.Object.defineProperty(brokenActivity, "toJsonLd", { + value: () => { + throw new Error("serialization failed"); + }, + }); + const dispatcher: CustomCollectionDispatcher< + Create, + string, + RequestContext, + void + > = () => ({ items: [brokenActivity] }); + const counter: CustomCollectionCounter = () => 1; + + await assertRejects( + () => + handleCustomCollection(context.request, { + context, + name: "custom collection", + values: { identifier: "someone" }, + collectionCallbacks: { dispatcher, counter }, + meterProvider, + onNotFound: () => new Response("Not found", { status: 404 }), + onUnauthorized: () => new Response("Unauthorized", { status: 401 }), + }), + Error, + "serialization failed", + ); + + const requests = recorder.getMeasurements("activitypub.collection.request"); + assertEquals(requests.length, 1); + assertEquals( + requests[0].attributes["activitypub.collection.result"], + "error", + ); + + const durations = recorder.getMeasurements( + "activitypub.collection.dispatch.duration", + ); + assertEquals(durations.length, 1); + assertEquals( + durations[0].attributes["activitypub.collection.result"], + "error", + ); + + const items = recorder.getMeasurements("activitypub.collection.page.items"); + assertEquals(items.length, 1); + assertEquals(items[0].value, 1); + assertEquals( + items[0].attributes["activitypub.collection.result"], + "error", + ); + + const totalItems = recorder.getMeasurements( + "activitypub.collection.total_items", + ); + assertEquals(totalItems.length, 1); + assertEquals(totalItems[0].value, 1); + assertEquals( + totalItems[0].attributes["activitypub.collection.result"], + "error", + ); +}); + test("handleInbox() records OpenTelemetry span events", async () => { const [tracerProvider, exporter] = createTestTracerProvider(); const [meterProvider, recorder] = createTestMeterProvider(); diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index 3c735b509..b7132af18 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -349,22 +349,21 @@ type CollectionMetricBase = Pick< "kind" | "page" | "dispatcher" >; +const BUILT_IN_COLLECTION_METRIC_KINDS = new Set([ + "inbox", + "outbox", + "following", + "followers", + "liked", + "featured", + "featured_tags", +]); + function getCollectionMetricKind(name: string): CollectionMetricKind { - switch (name.trim().toLowerCase().replace(/\s+/g, "_")) { - case "inbox": - case "outbox": - case "following": - case "followers": - case "liked": - case "featured": - case "featured_tags": - return name.trim().toLowerCase().replace( - /\s+/g, - "_", - ) as CollectionMetricKind; - default: - return "custom"; - } + const normalized = name.trim().toLowerCase().replace(/\s+/g, "_"); + return BUILT_IN_COLLECTION_METRIC_KINDS.has(normalized) + ? normalized as CollectionMetricKind + : "custom"; } function collectionAttributes( @@ -1737,6 +1736,14 @@ export const handleCustomCollection: < TContextData >, ) => Promise = exceptWrapper(_handleCustomCollection); + +type CollectionMetricMeasurement = { + page: boolean; + dispatchDurationMs?: number; + itemCount?: number; + totalItems?: number; +}; + async function _handleCustomCollection< TItem extends URL | Object | Link | Recipient, TParam extends string, @@ -1762,7 +1769,7 @@ async function _handleCustomCollection< verifyDefined(callbacks); await authIfNeeded(context, values, callbacks); const cursor = new URL(request.url).searchParams.get("cursor"); - return await new CustomCollectionHandler( + const handler = new CustomCollectionHandler( name, values, context, @@ -1772,9 +1779,17 @@ async function _handleCustomCollection< Collection, CollectionPage, filterPredicate, - ).fetchCollection(cursor) - .toJsonLd() - .then(respondAsActivity); + ).fetchCollection(cursor); + try { + const response = await handler.toJsonLd().then(respondAsActivity); + handler.recordPendingCollectionMetrics("served"); + return response; + } catch (e) { + handler.recordPendingCollectionMetrics( + e instanceof ItemsNotFoundError ? "not_found" : "error", + ); + throw e; + } } /** @@ -1827,7 +1842,7 @@ async function _handleOrderedCollection< verifyDefined(callbacks); await authIfNeeded(context, values, callbacks); const cursor = new URL(request.url).searchParams.get("cursor"); - return await new CustomCollectionHandler( + const handler = new CustomCollectionHandler( name, values, context, @@ -1837,9 +1852,17 @@ async function _handleOrderedCollection< OrderedCollection, OrderedCollectionPage, filterPredicate, - ).fetchCollection(cursor) - .toJsonLd() - .then(respondAsActivity); + ).fetchCollection(cursor); + try { + const response = await handler.toJsonLd().then(respondAsActivity); + handler.recordPendingCollectionMetrics("served"); + return response; + } catch (e) { + handler.recordPendingCollectionMetrics( + e instanceof ItemsNotFoundError ? "not_found" : "error", + ); + throw e; + } } /** @@ -1891,6 +1914,7 @@ class CustomCollectionHandler< TContextData >; #collection: Promise | null = null; + #pendingCollectionMetrics: CollectionMetricMeasurement[] = []; /** * Creates a new CustomCollection instance. @@ -2001,11 +2025,10 @@ class CustomCollectionHandler< ); const totalItems = await this.totalItems; if (totalItems != null) { - recordCollectionTotalItems( - this.meterProvider, + this.#pendingCollectionMetrics.push({ + page: false, totalItems, - this.metricAttributes(false, "served"), - ); + }); } return { id: this.#id, @@ -2085,28 +2108,18 @@ class CustomCollectionHandler< const page = await this.dispatch(cursor); const durationMs = getDurationMs(started); span.setAttribute(this.ATTRS.ITEMS, page.items.length); - const attrs = collectionAttributes(pageMetricBase, "served"); - recordCollectionDispatchDuration( - this.meterProvider, - durationMs, - attrs, - ); - recordCollectionPageItems( - this.meterProvider, - page.items.length, - attrs, - ); - if (totalItems !== null) { - recordCollectionTotalItems(this.meterProvider, totalItems, attrs); - } + this.#pendingCollectionMetrics.push({ + page: pageMetricBase.page, + dispatchDurationMs: durationMs, + itemCount: page.items.length, + totalItems: totalItems ?? undefined, + }); return page; } catch (e) { - const result = e instanceof ItemsNotFoundError ? "not_found" : "error"; - recordCollectionDispatchDuration( - this.meterProvider, - getDurationMs(started), - this.metricAttributes(cursor !== null, result), - ); + this.#pendingCollectionMetrics.push({ + page: cursor !== null, + dispatchDurationMs: getDurationMs(started), + }); const message = e instanceof Error ? e.message : String(e); span.setStatus({ code: SpanStatusCode.ERROR, message }); throw e; @@ -2150,6 +2163,33 @@ class CustomCollectionHandler< return collectionAttributes(this.metricBase(page), result); } + recordPendingCollectionMetrics(result: CollectionMetricResult): void { + for (const measurement of this.#pendingCollectionMetrics.splice(0)) { + const attrs = this.metricAttributes(measurement.page, result); + if (measurement.dispatchDurationMs != null) { + recordCollectionDispatchDuration( + this.meterProvider, + measurement.dispatchDurationMs, + attrs, + ); + } + if (measurement.itemCount != null) { + recordCollectionPageItems( + this.meterProvider, + measurement.itemCount, + attrs, + ); + } + if (measurement.totalItems != null) { + recordCollectionTotalItems( + this.meterProvider, + measurement.totalItems, + attrs, + ); + } + } + } + /** * Appends a cursor to the URL if it exists. * @param cursor The cursor to append, or null/undefined. From dec47bad87be0114ba5accbbca1eb9566ccb563d Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 25 May 2026 10:00:27 +0900 Subject: [PATCH 05/10] Fix custom collection metrics Keep custom collection histogram attributes aligned with the final response status, including served and not_found responses. Record page item counts after fallback filtering so metrics match the collection payload Fedify serializes, and normalize camelCase collection names when classifying built-in collection metric kinds. https://github.com/fedify-dev/fedify/pull/777#discussion_r3295597614 https://github.com/fedify-dev/fedify/pull/777#discussion_r3295597725 https://github.com/fedify-dev/fedify/pull/777#discussion_r3295598958 https://github.com/fedify-dev/fedify/pull/777#discussion_r3295598961 Assisted-by: Codex:gpt-5.5 --- .../fedify/src/federation/handler.test.ts | 96 ++++++++++++++- packages/fedify/src/federation/handler.ts | 114 +++++++++++++++--- 2 files changed, 194 insertions(+), 16 deletions(-) diff --git a/packages/fedify/src/federation/handler.test.ts b/packages/fedify/src/federation/handler.test.ts index 1a53ad40f..06c2156f3 100644 --- a/packages/fedify/src/federation/handler.test.ts +++ b/packages/fedify/src/federation/handler.test.ts @@ -1240,6 +1240,52 @@ test("handleCollection() records OpenTelemetry collection metrics", async () => assertEquals(totalItems[0].value, 3); }); +test("handleCollection() classifies camelCase collection metric names", async () => { + const [meterProvider, recorder] = createTestMeterProvider(); + const federation = createFederation({ + kv: new MemoryKvStore(), + meterProvider, + }); + const context = createRequestContext({ + federation, + data: undefined, + url: new URL("https://example.com/users/someone/collections/featured"), + request: new Request( + "https://example.com/users/someone/collections/featured", + { headers: { Accept: "application/activity+json" } }, + ), + }); + const dispatcher: CollectionDispatcher< + Activity, + RequestContext, + void, + void + > = () => ({ items: [] }); + + const response = await handleCollection(context.request, { + context, + name: "featuredTags", + identifier: "someone", + uriGetter(identifier) { + return new URL( + `https://example.com/users/${identifier}/collections/featured`, + ); + }, + collectionCallbacks: { dispatcher }, + meterProvider, + onNotFound: () => new Response("Not found", { status: 404 }), + onUnauthorized: () => new Response("Unauthorized", { status: 401 }), + }); + assertEquals(response.status, 200); + + const requests = recorder.getMeasurements("activitypub.collection.request"); + assertEquals(requests.length, 1); + assertEquals( + requests[0].attributes["activitypub.collection.kind"], + "featured_tags", + ); +}); + test("handleCollection() records not_found collection metrics", async () => { const [meterProvider, recorder] = createTestMeterProvider(); const federation = createFederation({ @@ -4210,6 +4256,8 @@ test("handleCustomCollection() records OpenTelemetry collection metrics", async name: "custom collection", values: { identifier: "someone" }, collectionCallbacks: { dispatcher, counter }, + filterPredicate: (item) => + item.id?.href === "https://example.com/activities/1", meterProvider, onNotFound: () => new Response("Not found", { status: 404 }), onUnauthorized: () => new Response("Unauthorized", { status: 401 }), @@ -4238,16 +4286,62 @@ test("handleCustomCollection() records OpenTelemetry collection metrics", async durations[0].attributes["fedify.collection.dispatcher"], "custom", ); + assertEquals(durations[0].attributes["http.response.status_code"], 200); const items = recorder.getMeasurements("activitypub.collection.page.items"); assertEquals(items.length, 1); - assertEquals(items[0].value, 2); + assertEquals(items[0].value, 1); + assertEquals(items[0].attributes["http.response.status_code"], 200); const totalItems = recorder.getMeasurements( "activitypub.collection.total_items", ); assertEquals(totalItems.length, 1); assertEquals(totalItems[0].value, 2); + assertEquals(totalItems[0].attributes["http.response.status_code"], 200); +}); + +test("handleCustomCollection() records not_found status on dispatch metrics", async () => { + const [meterProvider, recorder] = createTestMeterProvider(); + const federation = createFederation({ + kv: new MemoryKvStore(), + meterProvider, + }); + const context = createRequestContext({ + federation, + data: undefined, + url: new URL("https://example.com/users/nobody/custom"), + request: new Request("https://example.com/users/nobody/custom", { + headers: { Accept: "application/activity+json" }, + }), + }); + const dispatcher: CustomCollectionDispatcher< + Create, + string, + RequestContext, + void + > = () => null; + + const response = await handleCustomCollection(context.request, { + context, + name: "custom collection", + values: { identifier: "nobody" }, + collectionCallbacks: { dispatcher }, + meterProvider, + onNotFound: () => new Response("Not found", { status: 404 }), + onUnauthorized: () => new Response("Unauthorized", { status: 401 }), + }); + assertEquals(response.status, 404); + + const durations = recorder.getMeasurements( + "activitypub.collection.dispatch.duration", + ); + assertEquals(durations.length, 1); + assertEquals( + durations[0].attributes["activitypub.collection.result"], + "not_found", + ); + assertEquals(durations[0].attributes["http.response.status_code"], 404); }); test("handleCustomCollection() classifies deferred collection metrics as error", async () => { diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index b7132af18..48e1a15be 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -360,7 +360,10 @@ const BUILT_IN_COLLECTION_METRIC_KINDS = new Set([ ]); function getCollectionMetricKind(name: string): CollectionMetricKind { - const normalized = name.trim().toLowerCase().replace(/\s+/g, "_"); + const normalized = name.trim() + .replace(/([a-z0-9])([A-Z])/g, "$1_$2") + .toLowerCase() + .replace(/\s+/g, "_"); return BUILT_IN_COLLECTION_METRIC_KINDS.has(normalized) ? normalized as CollectionMetricKind : "custom"; @@ -1744,6 +1747,46 @@ type CollectionMetricMeasurement = { totalItems?: number; }; +type PendingCollectionMetricRecorder = ( + result: CollectionMetricResult, + response?: Response, +) => void; + +const pendingCollectionMetricRecorders = new WeakMap< + object, + PendingCollectionMetricRecorder +>(); + +function deferPendingCollectionMetrics( + error: unknown, + recorder: PendingCollectionMetricRecorder, +): boolean { + if ( + error == null || + (typeof error !== "object" && typeof error !== "function") + ) { + return false; + } + pendingCollectionMetricRecorders.set(error, recorder); + return true; +} + +function recordDeferredPendingCollectionMetrics( + error: unknown, + result: CollectionMetricResult, + response?: Response, +): void { + if ( + error == null || + (typeof error !== "object" && typeof error !== "function") + ) { + return; + } + const recorder = pendingCollectionMetricRecorders.get(error); + pendingCollectionMetricRecorders.delete(error); + recorder?.(result, response); +} + async function _handleCustomCollection< TItem extends URL | Object | Link | Recipient, TParam extends string, @@ -1782,12 +1825,16 @@ async function _handleCustomCollection< ).fetchCollection(cursor); try { const response = await handler.toJsonLd().then(respondAsActivity); - handler.recordPendingCollectionMetrics("served"); + handler.recordPendingCollectionMetrics("served", response); return response; } catch (e) { - handler.recordPendingCollectionMetrics( - e instanceof ItemsNotFoundError ? "not_found" : "error", - ); + if ( + !deferPendingCollectionMetrics( + e, + (result, response) => + handler.recordPendingCollectionMetrics(result, response), + ) + ) handler.recordPendingCollectionMetrics("error"); throw e; } } @@ -1855,12 +1902,16 @@ async function _handleOrderedCollection< ).fetchCollection(cursor); try { const response = await handler.toJsonLd().then(respondAsActivity); - handler.recordPendingCollectionMetrics("served"); + handler.recordPendingCollectionMetrics("served", response); return response; } catch (e) { - handler.recordPendingCollectionMetrics( - e instanceof ItemsNotFoundError ? "not_found" : "error", - ); + if ( + !deferPendingCollectionMetrics( + e, + (result, response) => + handler.recordPendingCollectionMetrics(result, response), + ) + ) handler.recordPendingCollectionMetrics("error"); throw e; } } @@ -2003,10 +2054,12 @@ class CustomCollectionHandler< const { prevCursor, nextCursor } = pages; const partOf = new URL(id); partOf.searchParams.delete("cursor"); + const items = this.filterItems(pages.items); + this.recordPendingCollectionItemCount(true, items.length); return { id, partOf, - items: this.filterItems(pages.items), + items, prev: this.appendToUrl(prevCursor), next: this.appendToUrl(nextCursor), }; @@ -2045,10 +2098,12 @@ class CustomCollectionHandler< async getPropsWithoutCursor() { const totalItems = await this.totalItems; const pages = await this.getPages({ totalItems }); + const items = this.filterItems(pages.items); + this.recordPendingCollectionItemCount(false, items.length); return { id: this.#id, totalItems, - items: this.filterItems(pages.items), + items, }; } @@ -2111,7 +2166,6 @@ class CustomCollectionHandler< this.#pendingCollectionMetrics.push({ page: pageMetricBase.page, dispatchDurationMs: durationMs, - itemCount: page.items.length, totalItems: totalItems ?? undefined, }); return page; @@ -2159,13 +2213,32 @@ class CustomCollectionHandler< metricAttributes( page: boolean, result: CollectionMetricResult, + response?: Response, ): CollectionMetricAttributes { - return collectionAttributes(this.metricBase(page), result); + return collectionAttributes(this.metricBase(page), result, response); } - recordPendingCollectionMetrics(result: CollectionMetricResult): void { + recordPendingCollectionItemCount(page: boolean, itemCount: number): void { + for (let i = this.#pendingCollectionMetrics.length - 1; i >= 0; i--) { + const measurement = this.#pendingCollectionMetrics[i]; + if ( + measurement.page === page && + measurement.dispatchDurationMs != null && + measurement.itemCount == null + ) { + measurement.itemCount = itemCount; + return; + } + } + this.#pendingCollectionMetrics.push({ page, itemCount }); + } + + recordPendingCollectionMetrics( + result: CollectionMetricResult, + response?: Response, + ): void { for (const measurement of this.#pendingCollectionMetrics.splice(0)) { - const attrs = this.metricAttributes(measurement.page, result); + const attrs = this.metricAttributes(measurement.page, result, response); if (measurement.dispatchDurationMs != null) { recordCollectionDispatchDuration( this.meterProvider, @@ -2294,6 +2367,11 @@ function exceptWrapper( switch (error?.constructor) { case ItemsNotFoundError: { const response = await onNotFound(request); + recordDeferredPendingCollectionMetrics( + error, + "not_found", + response, + ); recordCollectionRequest( meterProvider, collectionAttributes(metricBase, "not_found", response), @@ -2302,6 +2380,11 @@ function exceptWrapper( } case UnauthorizedError: { const response = await onUnauthorized(request); + recordDeferredPendingCollectionMetrics( + error, + "unauthorized", + response, + ); recordCollectionRequest( meterProvider, collectionAttributes(metricBase, "unauthorized", response), @@ -2309,6 +2392,7 @@ function exceptWrapper( return response; } default: + recordDeferredPendingCollectionMetrics(error, "error"); recordCollectionRequest( meterProvider, collectionAttributes(metricBase, "error"), From 91acb27db83d9582d3754c0e3c43280ca1f145ee Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 25 May 2026 10:21:46 +0900 Subject: [PATCH 06/10] Document collection meter providers Add public API documentation for the collection meterProvider options so the telemetry hook appears alongside the existing handler parameter docs. Assisted-by: Codex:gpt-5.5 --- packages/fedify/src/federation/handler.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index 48e1a15be..053b89e64 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -339,6 +339,10 @@ export interface CollectionHandlerParameters< TFilter >; tracerProvider?: TracerProvider; + /** + * The meter provider for recording collection metrics. + * @since 2.3.0 + */ meterProvider?: MeterProvider; onUnauthorized(request: Request): Response | Promise; onNotFound(request: Request): Response | Promise; @@ -1711,6 +1715,10 @@ export interface CustomCollectionHandlerParameters< TContextData >; tracerProvider?: TracerProvider; + /** + * The meter provider for recording collection metrics. + * @since 2.3.0 + */ meterProvider?: MeterProvider; } From 6f56e1edc4924757c911c675ede5c38284e6a722 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 25 May 2026 10:38:05 +0900 Subject: [PATCH 07/10] Record filtered collection item counts Record built-in collection item metrics after fallback filtering so activitypub.collection.page.items matches the response payload served to clients. Add regression coverage for both whole collection and paginated collection responses. https://github.com/fedify-dev/fedify/pull/777#discussion_r3295641807 https://github.com/fedify-dev/fedify/pull/777#discussion_r3295643781 https://github.com/fedify-dev/fedify/pull/777#discussion_r3295643785 https://github.com/fedify-dev/fedify/pull/777#discussion_r3295643788 https://github.com/fedify-dev/fedify/pull/777#discussion_r3295643792 Assisted-by: Codex:gpt-5.5 --- .../fedify/src/federation/handler.test.ts | 95 +++++++++++++++++++ packages/fedify/src/federation/handler.ts | 19 +++- 2 files changed, 109 insertions(+), 5 deletions(-) diff --git a/packages/fedify/src/federation/handler.test.ts b/packages/fedify/src/federation/handler.test.ts index 06c2156f3..e6268a05e 100644 --- a/packages/fedify/src/federation/handler.test.ts +++ b/packages/fedify/src/federation/handler.test.ts @@ -1286,6 +1286,101 @@ test("handleCollection() classifies camelCase collection metric names", async () ); }); +test("handleCollection() records filtered collection item metrics", async () => { + const [meterProvider, recorder] = createTestMeterProvider(); + const federation = createFederation({ + kv: new MemoryKvStore(), + meterProvider, + }); + const context = createRequestContext({ + federation, + data: undefined, + url: new URL("https://example.com/users/someone/followers"), + request: new Request("https://example.com/users/someone/followers", { + headers: { Accept: "application/activity+json" }, + }), + }); + const dispatcher: CollectionDispatcher< + Activity, + RequestContext, + void, + void + > = () => ({ + items: [ + new Create({ id: new URL("https://example.com/activities/1") }), + new Create({ id: new URL("https://example.com/activities/2") }), + ], + }); + + const response = await handleCollection(context.request, { + context, + name: "followers", + identifier: "someone", + uriGetter(identifier) { + return new URL(`https://example.com/users/${identifier}/followers`); + }, + collectionCallbacks: { dispatcher }, + filterPredicate: (item) => + item.id?.href === "https://example.com/activities/1", + meterProvider, + onNotFound: () => new Response("Not found", { status: 404 }), + onUnauthorized: () => new Response("Unauthorized", { status: 401 }), + }); + assertEquals(response.status, 200); + + const items = recorder.getMeasurements("activitypub.collection.page.items"); + assertEquals(items.length, 1); + assertEquals(items[0].value, 1); +}); + +test("handleCollection() records filtered collection page item metrics", async () => { + const [meterProvider, recorder] = createTestMeterProvider(); + const federation = createFederation({ + kv: new MemoryKvStore(), + meterProvider, + }); + const context = createRequestContext({ + federation, + data: undefined, + url: new URL("https://example.com/users/someone/followers?cursor=2"), + request: new Request( + "https://example.com/users/someone/followers?cursor=2", + { headers: { Accept: "application/activity+json" } }, + ), + }); + const dispatcher: CollectionDispatcher< + Activity, + RequestContext, + void, + void + > = () => ({ + items: [ + new Create({ id: new URL("https://example.com/activities/1") }), + new Create({ id: new URL("https://example.com/activities/2") }), + ], + }); + + const response = await handleCollection(context.request, { + context, + name: "followers", + identifier: "someone", + uriGetter(identifier) { + return new URL(`https://example.com/users/${identifier}/followers`); + }, + collectionCallbacks: { dispatcher }, + filterPredicate: (item) => + item.id?.href === "https://example.com/activities/1", + meterProvider, + onNotFound: () => new Response("Not found", { status: 404 }), + onUnauthorized: () => new Response("Unauthorized", { status: 401 }), + }); + assertEquals(response.status, 200); + + const items = recorder.getMeasurements("activitypub.collection.page.items"); + assertEquals(items.length, 1); + assertEquals(items[0].value, 1); +}); + test("handleCollection() records not_found collection metrics", async () => { const [meterProvider, recorder] = createTestMeterProvider(); const federation = createFederation({ diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index 053b89e64..85b16e055 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -514,7 +514,11 @@ export async function handleCollection< span.setStatus({ code: SpanStatusCode.ERROR }); return await onNotFound(request); } - const { items } = page; + const items = filterCollectionItems( + page.items, + name, + filterPredicate, + ); itemCount = items.length; span.setAttribute("fedify.collection.items", itemCount); return items; @@ -538,7 +542,7 @@ export async function handleCollection< collection = new OrderedCollection({ id: baseUri, totalItems: totalItemCount ?? null, - items: filterCollectionItems(itemsOrResponse, name, filterPredicate), + items: itemsOrResponse, }); } else { const lastCursor = await collectionCallbacks.lastCursor?.( @@ -586,9 +590,14 @@ export async function handleCollection< span.setStatus({ code: SpanStatusCode.ERROR }); return await onNotFound(request); } - itemCount = page.items.length; + const items = filterCollectionItems( + page.items, + name, + filterPredicate, + ); + itemCount = items.length; span.setAttribute("fedify.collection.items", itemCount); - return page; + return { ...page, items }; } catch (e) { if (dispatchDurationMs == null) { dispatchDurationMs = getDurationMs(started); @@ -623,7 +632,7 @@ export async function handleCollection< id: uri, prev, next, - items: filterCollectionItems(items, name, filterPredicate), + items, partOf, }); } From 7be48c72cbc3a813f71b03890d961fef533f9cc9 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 25 May 2026 10:54:36 +0900 Subject: [PATCH 08/10] Harden filtered collection metric assertions The review caught that the filtered item-count regression tests only checked the recorded value. Assert the key collection metric attributes as well so the tests also cover page classification, served result, and HTTP status metadata. Addresses: https://github.com/fedify-dev/fedify/pull/777#discussion_r3295721801 Assisted-by: Codex:gpt-5.5 --- packages/fedify/src/federation/handler.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/fedify/src/federation/handler.test.ts b/packages/fedify/src/federation/handler.test.ts index e6268a05e..d16ad2bb2 100644 --- a/packages/fedify/src/federation/handler.test.ts +++ b/packages/fedify/src/federation/handler.test.ts @@ -1331,6 +1331,9 @@ test("handleCollection() records filtered collection item metrics", async () => const items = recorder.getMeasurements("activitypub.collection.page.items"); assertEquals(items.length, 1); assertEquals(items[0].value, 1); + assertEquals(items[0].attributes["activitypub.collection.page"], false); + assertEquals(items[0].attributes["activitypub.collection.result"], "served"); + assertEquals(items[0].attributes["http.response.status_code"], 200); }); test("handleCollection() records filtered collection page item metrics", async () => { @@ -1379,6 +1382,9 @@ test("handleCollection() records filtered collection page item metrics", async ( const items = recorder.getMeasurements("activitypub.collection.page.items"); assertEquals(items.length, 1); assertEquals(items[0].value, 1); + assertEquals(items[0].attributes["activitypub.collection.page"], true); + assertEquals(items[0].attributes["activitypub.collection.result"], "served"); + assertEquals(items[0].attributes["http.response.status_code"], 200); }); test("handleCollection() records not_found collection metrics", async () => { From 2c74e20bd489d3eccd7d5f694b371335245f2d63 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 25 May 2026 12:13:15 +0900 Subject: [PATCH 09/10] Type custom collection meter access The custom collection error wrapper reads the optional meter provider from its handler parameters. Put that optional telemetry hook on the internal ErrorHandlers bound so the wrapper no longer needs a local type assertion. https://github.com/fedify-dev/fedify/pull/777#discussion_r3295754948 Assisted-by: Codex:gpt-5.5 --- packages/fedify/src/federation/handler.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index 85b16e055..f53c7281d 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -2364,9 +2364,7 @@ function exceptWrapper( ): (...args: Parameters) => Promise { return async (request, handlerParams): Promise => { const page = new URL(request.url).searchParams.get("cursor") != null; - const meterProvider = - (handlerParams as ErrorHandlers & { meterProvider?: MeterProvider }) - .meterProvider; + const { meterProvider } = handlerParams; const metricBase: CollectionMetricBase = { kind: "custom", page, @@ -2425,6 +2423,7 @@ function exceptWrapper( * @since 1.8.0 */ interface ErrorHandlers { + meterProvider?: MeterProvider; onNotFound(request: Request): Response | Promise; onUnauthorized(request: Request): Response | Promise; } From 6534413aa0f9fe5139f1deb2a21af99e3720d8f8 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Mon, 25 May 2026 12:30:24 +0900 Subject: [PATCH 10/10] Normalize custom total item metrics Custom collection counters can originate from number or bigint values. The handler already normalizes them through the totalItems accessor, and these metric writes now make that number conversion explicit at the measurement boundary too. https://github.com/fedify-dev/fedify/pull/777#discussion_r3295900056 https://github.com/fedify-dev/fedify/pull/777#discussion_r3295900057 Assisted-by: Codex:gpt-5.5 --- packages/fedify/src/federation/handler.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/fedify/src/federation/handler.ts b/packages/fedify/src/federation/handler.ts index f53c7281d..78c9fbcef 100644 --- a/packages/fedify/src/federation/handler.ts +++ b/packages/fedify/src/federation/handler.ts @@ -2097,7 +2097,7 @@ class CustomCollectionHandler< if (totalItems != null) { this.#pendingCollectionMetrics.push({ page: false, - totalItems, + totalItems: Number(totalItems), }); } return { @@ -2183,7 +2183,7 @@ class CustomCollectionHandler< this.#pendingCollectionMetrics.push({ page: pageMetricBase.page, dispatchDurationMs: durationMs, - totalItems: totalItems ?? undefined, + totalItems: totalItems == null ? undefined : Number(totalItems), }); return page; } catch (e) {