From b46c62741b3f7e1ecc009ff83efab6f8c6f3ee62 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Thu, 7 May 2026 15:00:53 +0900 Subject: [PATCH 1/5] Add OpenTelemetry HTTP server metrics for Federation.fetch() Sampled traces alone cannot reliably answer aggregate operational questions like request rate, p95 latency, or status-code error rate. Add two always-on instruments around Federation.fetch(): - fedify.http.server.request.count (Counter, {request}) - fedify.http.server.request.duration (Histogram, ms) Both carry low-cardinality attributes: http.request.method, fedify.endpoint, optional http.response.status_code, and optional fedify.route.template. Raw URL paths, identifier values, query strings, and full inbox URLs are deliberately excluded so that operators can rely on the metrics even when traces are sampled. The fedify.endpoint attribute is drawn from a fixed enumeration covering the routes Fedify dispatches (webfinger, nodeinfo, actor, inbox, shared_inbox, outbox, object, the built-in collections, generic collection for user-defined dispatchers, plus not_found, not_acceptable, and error). When a handler throws after the route was already classified, the metric retains the matched endpoint; error is reserved for the pre-classification failure mode so fault-attribution stays per endpoint. http.request.method is normalized to the OpenTelemetry-standard set (CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT, TRACE) and falls back to _OTHER for any other value, so an arbitrary client cannot inflate metric cardinality with custom methods. The metric is implemented on the existing FederationMetrics class in metrics.ts and wired into the existing fetch()/#fetch() pair via a small mutable HttpMetricState carrier. Tests cover the acceptance criteria from the issue (success, 404, 406, thrown error) plus shared-inbox routing, route-template emission, the endpoint enum extensions, the global-MeterProvider fallback, and the method-normalization regression. https://github.com/fedify-dev/fedify/issues/316 https://github.com/fedify-dev/fedify/issues/736 Assisted-by: Claude Code:claude-opus-4-7 Assisted-by: Codex:gpt-5.5 --- CHANGES.md | 12 + docs/manual/opentelemetry.md | 49 ++- packages/fedify/src/federation/metrics.ts | 53 +++ .../fedify/src/federation/middleware.test.ts | 311 ++++++++++++++++++ packages/fedify/src/federation/middleware.ts | 78 ++++- 5 files changed, 494 insertions(+), 9 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index c57a58599..f691c4e63 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -41,13 +41,25 @@ To be released. attributes, and `TraceActivityRecord.activityJson` is present only when the span event includes full activity JSON. [[#316], [#619], [#755]] + - Added OpenTelemetry HTTP server metrics for inbound requests handled by + `Federation.fetch()`: `fedify.http.server.request.count` (Counter) and + `fedify.http.server.request.duration` (Histogram). Both instruments carry + `http.request.method`, `fedify.endpoint`, optional + `http.response.status_code`, and optional `fedify.route.template` + attributes so that operators can monitor aggregate request rate, latency, + and status-code error rate even when traces are sampled. Attributes + deliberately exclude raw URLs, query strings, and identifier values to + keep cardinality bounded. [[#316], [#736], [#757]] + [#316]: https://github.com/fedify-dev/fedify/issues/316 [#619]: https://github.com/fedify-dev/fedify/issues/619 [#735]: https://github.com/fedify-dev/fedify/issues/735 +[#736]: https://github.com/fedify-dev/fedify/issues/736 [#748]: https://github.com/fedify-dev/fedify/pull/748 [#752]: https://github.com/fedify-dev/fedify/issues/752 [#753]: https://github.com/fedify-dev/fedify/pull/753 [#755]: https://github.com/fedify-dev/fedify/pull/755 +[#757]: https://github.com/fedify-dev/fedify/pull/757 ### @fedify/fixture diff --git a/docs/manual/opentelemetry.md b/docs/manual/opentelemetry.md index ff81f27dc..da2120786 100644 --- a/docs/manual/opentelemetry.md +++ b/docs/manual/opentelemetry.md @@ -296,13 +296,15 @@ Instrumented metrics Fedify records the following OpenTelemetry metrics: -| Metric name | Instrument | Unit | Description | -| -------------------------------------------- | ---------- | ----------- | ----------------------------------------------------------- | -| `activitypub.delivery.sent` | Counter | `{attempt}` | Counts outgoing ActivityPub delivery attempts. | -| `activitypub.delivery.permanent_failure` | Counter | `{failure}` | Counts outgoing deliveries abandoned as permanent failures. | -| `activitypub.delivery.duration` | Histogram | `ms` | Measures outgoing ActivityPub delivery attempt duration. | -| `activitypub.inbox.processing_duration` | Histogram | `ms` | Measures inbox listener processing duration. | -| `activitypub.signature.verification_failure` | Counter | `{failure}` | Counts failed signature verification for inbox requests. | +| Metric name | Instrument | Unit | Description | +| -------------------------------------------- | ---------- | ----------- | --------------------------------------------------------------- | +| `activitypub.delivery.sent` | Counter | `{attempt}` | Counts outgoing ActivityPub delivery attempts. | +| `activitypub.delivery.permanent_failure` | Counter | `{failure}` | Counts outgoing deliveries abandoned as permanent failures. | +| `activitypub.delivery.duration` | Histogram | `ms` | Measures outgoing ActivityPub delivery attempt duration. | +| `activitypub.inbox.processing_duration` | Histogram | `ms` | Measures inbox listener processing duration. | +| `activitypub.signature.verification_failure` | Counter | `{failure}` | Counts failed signature verification for inbox requests. | +| `fedify.http.server.request.count` | Counter | `{request}` | Counts inbound HTTP requests handled by `Federation.fetch()`. | +| `fedify.http.server.request.duration` | Histogram | `ms` | Measures inbound HTTP request duration in `Federation.fetch()`. | ### Metric attributes @@ -324,11 +326,42 @@ Fedify records the following OpenTelemetry metrics: : `activitypub.verification.failure_reason`, plus `activitypub.remote.host` when the failed signature includes a key ID. +`fedify.http.server.request.count` and `fedify.http.server.request.duration` +: `http.request.method` and `fedify.endpoint` are always present. + `http.request.method` is normalized to one of the standard HTTP methods + (`CONNECT`, `DELETE`, `GET`, `HEAD`, `OPTIONS`, `PATCH`, `POST`, `PUT`, + `TRACE`) or `_OTHER` for any other value, so that an arbitrary client + cannot inflate metric cardinality by sending custom methods. + `http.response.status_code` is recorded when a `Response` is produced + (success and non-2xx alike) and omitted when the request threw an + exception before a response could be returned. `fedify.route.template` + is recorded when a route matched, and contains the [URI Template] + parameter names (for example `/users/{identifier}`) rather than the + matched parameter values. + Fedify records `activitypub.remote.host` as the URL hostname only; ports, paths, and query strings are deliberately excluded to keep metric cardinality bounded. Activity types use the same qualified URI form as Fedify's trace attributes, for example `https://www.w3.org/ns/activitystreams#Create`. +The HTTP server request metrics deliberately exclude high-cardinality fields +such as the full URL, raw path, query string, actor identifier, and inbox +URL. Use the request span's `url.full` attribute when you need the exact URL +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 `fedify.endpoint` attribute is drawn from a fixed enumeration: +`webfinger`, `nodeinfo`, `actor`, `inbox`, `shared_inbox`, `outbox`, +`object`, `following`, `followers`, `liked`, `featured`, `featured_tags`, +`collection`, `not_found`, `not_acceptable`, and `error`. When a request +throws an exception after Fedify has already classified its endpoint, the +metric retains the matched endpoint (for example `actor`) so that +fault-attribution stays per endpoint; `error` is only used when classification +itself failed. + +[URI Template]: https://datatracker.ietf.org/doc/html/rfc6570 + Semantic [attributes] for ActivityPub ------------------------------------- @@ -367,6 +400,8 @@ for ActivityPub: | `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"]` | diff --git a/packages/fedify/src/federation/metrics.ts b/packages/fedify/src/federation/metrics.ts index 8399b375b..2fab40870 100644 --- a/packages/fedify/src/federation/metrics.ts +++ b/packages/fedify/src/federation/metrics.ts @@ -13,6 +13,8 @@ class FederationMetrics { readonly signatureVerificationFailure: Counter; readonly deliveryDuration: Histogram; readonly inboxProcessingDuration: Histogram; + readonly httpServerRequestCount: Counter; + readonly httpServerRequestDuration: Histogram; constructor(meterProvider: MeterProvider) { const meter = meterProvider.getMeter(metadata.name, metadata.version); @@ -48,6 +50,20 @@ class FederationMetrics { unit: "ms", }, ); + this.httpServerRequestCount = meter.createCounter( + "fedify.http.server.request.count", + { + description: "HTTP requests handled by Federation.fetch().", + unit: "{request}", + }, + ); + this.httpServerRequestDuration = meter.createHistogram( + "fedify.http.server.request.duration", + { + description: "Duration of HTTP requests handled by Federation.fetch().", + unit: "ms", + }, + ); } recordDelivery( @@ -95,6 +111,43 @@ class FederationMetrics { "activitypub.activity.type": activityType, }); } + + recordHttpServerRequest( + method: string, + endpoint: string, + durationMs: number, + options: { statusCode?: number; routeTemplate?: string } = {}, + ): void { + const attributes: Attributes = { + "http.request.method": normalizeHttpMethod(method), + "fedify.endpoint": endpoint, + }; + if (options.statusCode != null) { + attributes["http.response.status_code"] = options.statusCode; + } + if (options.routeTemplate != null) { + attributes["fedify.route.template"] = options.routeTemplate; + } + this.httpServerRequestCount.add(1, attributes); + this.httpServerRequestDuration.record(durationMs, attributes); + } +} + +const KNOWN_HTTP_METHODS: ReadonlySet = new Set([ + "CONNECT", + "DELETE", + "GET", + "HEAD", + "OPTIONS", + "PATCH", + "POST", + "PUT", + "TRACE", +]); + +function normalizeHttpMethod(method: string): string { + const upper = method.toUpperCase(); + return KNOWN_HTTP_METHODS.has(upper) ? upper : "_OTHER"; } const federationMetrics = new WeakMap(); diff --git a/packages/fedify/src/federation/middleware.test.ts b/packages/fedify/src/federation/middleware.test.ts index 681a52805..71d765798 100644 --- a/packages/fedify/src/federation/middleware.test.ts +++ b/packages/fedify/src/federation/middleware.test.ts @@ -1455,6 +1455,317 @@ test("Federation.fetch()", async (t) => { fetchMock.hardReset(); }); +test("Federation.fetch() records HTTP server request metrics", async (t) => { + const createTestContext = () => { + const kv = new MemoryKvStore(); + const [meterProvider, recorder] = createTestMeterProvider(); + const federation = createFederation({ + kv, + meterProvider, + documentLoaderFactory: () => mockDocumentLoader, + }); + + federation.setActorDispatcher( + "/users/{identifier}", + (ctx, identifier) => { + if (identifier === "boom") { + throw new Error("explosion in actor dispatcher"); + } + return new vocab.Person({ + id: ctx.getActorUri(identifier), + inbox: ctx.getInboxUri(identifier), + preferredUsername: identifier, + }); + }, + ); + + federation.setNodeInfoDispatcher("/nodeinfo/2.1", () => ({ + software: { name: "example", version: "1.0.0" }, + protocols: ["activitypub"], + usage: { users: {}, localPosts: 0, localComments: 0 }, + })); + + federation.setFollowersDispatcher( + "/users/{identifier}/followers", + () => ({ items: [] }), + ); + + federation.setInboxListeners("/users/{identifier}/inbox", "/inbox"); + + return { federation, recorder }; + }; + + await t.step("records a successful actor request", async () => { + const { federation, recorder } = createTestContext(); + const response = await federation.fetch( + new Request("https://example.com/users/alice", { + method: "GET", + headers: { "Accept": "application/activity+json" }, + }), + { contextData: undefined }, + ); + assertEquals(response.status, 200); + + const counts = recorder.getMeasurements("fedify.http.server.request.count"); + assertEquals(counts.length, 1); + assertEquals(counts[0].type, "counter"); + assertEquals(counts[0].value, 1); + assertEquals(counts[0].attributes["http.request.method"], "GET"); + assertEquals(counts[0].attributes["fedify.endpoint"], "actor"); + assertEquals(counts[0].attributes["http.response.status_code"], 200); + assertEquals( + counts[0].attributes["fedify.route.template"], + "/users/{identifier}", + ); + + const durations = recorder.getMeasurements( + "fedify.http.server.request.duration", + ); + assertEquals(durations.length, 1); + assertEquals(durations[0].type, "histogram"); + assert(durations[0].value >= 0); + assertEquals(durations[0].attributes["fedify.endpoint"], "actor"); + assertEquals(durations[0].attributes["http.response.status_code"], 200); + assertEquals( + durations[0].attributes["fedify.route.template"], + "/users/{identifier}", + ); + }); + + await t.step("records WebFinger requests", async () => { + const { federation, recorder } = createTestContext(); + const response = await federation.fetch( + new Request( + "https://example.com/.well-known/webfinger?resource=acct:alice@example.com", + ), + { contextData: undefined }, + ); + assertEquals(response.status, 200); + + const counts = recorder.getMeasurements("fedify.http.server.request.count"); + assertEquals(counts.length, 1); + assertEquals(counts[0].attributes["fedify.endpoint"], "webfinger"); + assertEquals( + counts[0].attributes["fedify.route.template"], + "/.well-known/webfinger", + ); + assertEquals(counts[0].attributes["http.response.status_code"], 200); + }); + + await t.step("records NodeInfo JRD requests", async () => { + const { federation, recorder } = createTestContext(); + const response = await federation.fetch( + new Request("https://example.com/.well-known/nodeinfo"), + { contextData: undefined }, + ); + assertEquals(response.status, 200); + + const counts = recorder.getMeasurements("fedify.http.server.request.count"); + assertEquals(counts.length, 1); + assertEquals(counts[0].attributes["fedify.endpoint"], "nodeinfo"); + assertEquals( + counts[0].attributes["fedify.route.template"], + "/.well-known/nodeinfo", + ); + }); + + await t.step("records NodeInfo dispatcher requests", async () => { + const { federation, recorder } = createTestContext(); + const response = await federation.fetch( + new Request("https://example.com/nodeinfo/2.1"), + { contextData: undefined }, + ); + assertEquals(response.status, 200); + + const counts = recorder.getMeasurements("fedify.http.server.request.count"); + assertEquals(counts.length, 1); + assertEquals(counts[0].attributes["fedify.endpoint"], "nodeinfo"); + assertEquals( + counts[0].attributes["fedify.route.template"], + "/nodeinfo/2.1", + ); + }); + + await t.step("records 404 not_found for unmatched paths", async () => { + const { federation, recorder } = createTestContext(); + const response = await federation.fetch( + new Request("https://example.com/no/such/path"), + { contextData: undefined }, + ); + assertEquals(response.status, 404); + + const counts = recorder.getMeasurements("fedify.http.server.request.count"); + assertEquals(counts.length, 1); + assertEquals(counts[0].attributes["fedify.endpoint"], "not_found"); + assertEquals(counts[0].attributes["http.response.status_code"], 404); + assertEquals(counts[0].attributes["fedify.route.template"], undefined); + }); + + await t.step( + "records 406 not_acceptable when JSON-LD Accept missing", + async () => { + const { federation, recorder } = createTestContext(); + const response = await federation.fetch( + new Request("https://example.com/users/alice", { + method: "GET", + headers: { "Accept": "text/html" }, + }), + { contextData: undefined }, + ); + assertEquals(response.status, 406); + + const counts = recorder.getMeasurements( + "fedify.http.server.request.count", + ); + assertEquals(counts.length, 1); + assertEquals(counts[0].attributes["fedify.endpoint"], "not_acceptable"); + assertEquals(counts[0].attributes["http.response.status_code"], 406); + assertEquals( + counts[0].attributes["fedify.route.template"], + "/users/{identifier}", + ); + }, + ); + + await t.step( + "records thrown errors after classification with the matched endpoint", + async () => { + const { federation, recorder } = createTestContext(); + await assertRejects( + () => + federation.fetch( + new Request("https://example.com/users/boom", { + method: "GET", + headers: { "Accept": "application/activity+json" }, + }), + { contextData: undefined }, + ), + Error, + "explosion", + ); + + const counts = recorder.getMeasurements( + "fedify.http.server.request.count", + ); + assertEquals(counts.length, 1); + assertEquals(counts[0].attributes["fedify.endpoint"], "actor"); + assertEquals( + counts[0].attributes["http.response.status_code"], + undefined, + ); + assertEquals( + counts[0].attributes["fedify.route.template"], + "/users/{identifier}", + ); + + const durations = recorder.getMeasurements( + "fedify.http.server.request.duration", + ); + assertEquals(durations.length, 1); + assertEquals(durations[0].attributes["fedify.endpoint"], "actor"); + }, + ); + + await t.step("records followers as endpoint=followers", async () => { + const { federation, recorder } = createTestContext(); + const response = await federation.fetch( + new Request("https://example.com/users/alice/followers", { + method: "GET", + headers: { "Accept": "application/activity+json" }, + }), + { contextData: undefined }, + ); + assertEquals(response.status, 200); + + const counts = recorder.getMeasurements("fedify.http.server.request.count"); + assertEquals(counts.length, 1); + assertEquals(counts[0].attributes["fedify.endpoint"], "followers"); + assertEquals( + counts[0].attributes["fedify.route.template"], + "/users/{identifier}/followers", + ); + }); + + await t.step("records sharedInbox as endpoint=shared_inbox", async () => { + const kv = new MemoryKvStore(); + const [meterProvider, recorder] = createTestMeterProvider(); + const federation = createFederation({ + kv, + meterProvider, + documentLoaderFactory: () => mockDocumentLoader, + }); + federation.setInboxListeners("/users/{identifier}/inbox", "/inbox"); + + const response = await federation.fetch( + new Request("https://example.com/inbox", { + method: "POST", + headers: { "accept": "application/ld+json" }, + }), + { contextData: undefined }, + ); + // Without an actor dispatcher signature verification fails — but the + // routing classification has already happened, which is what we assert. + assert(response.status >= 400); + + const counts = recorder.getMeasurements("fedify.http.server.request.count"); + assertEquals(counts.length, 1); + assertEquals(counts[0].attributes["fedify.endpoint"], "shared_inbox"); + assertEquals( + counts[0].attributes["fedify.route.template"], + "/inbox", + ); + assertEquals(counts[0].attributes["http.request.method"], "POST"); + }); + + await t.step( + "normalizes unknown HTTP methods to _OTHER for cardinality control", + async () => { + const { federation, recorder } = createTestContext(); + const response = await federation.fetch( + new Request("https://example.com/users/alice", { + method: "PROPFIND", + headers: { "Accept": "application/activity+json" }, + }), + { contextData: undefined }, + ); + // We only care about the metric attribute, not the response code here. + assert(response.status >= 100); + + const counts = recorder.getMeasurements( + "fedify.http.server.request.count", + ); + assertEquals(counts.length, 1); + assertEquals(counts[0].attributes["http.request.method"], "_OTHER"); + }, + ); + + await t.step( + "uses the global meter provider when none is configured", + async () => { + const kv = new MemoryKvStore(); + const federation = createFederation({ + kv, + documentLoaderFactory: () => mockDocumentLoader, + }); + federation.setActorDispatcher( + "/users/{identifier}", + (ctx, identifier) => + new vocab.Person({ id: ctx.getActorUri(identifier) }), + ); + + // Should not throw — the no-op meter provider absorbs the calls. + const response = await federation.fetch( + new Request("https://example.com/users/alice", { + method: "GET", + headers: { "Accept": "application/activity+json" }, + }), + { contextData: undefined }, + ); + assertEquals(response.status, 200); + }, + ); +}); + test("Federation.setInboxListeners()", async (t) => { const kv = new MemoryKvStore(); diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index 3ccbcba1a..f0e114edc 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -1366,6 +1366,8 @@ export class FederationImpl const requestId = getRequestId(request); return withContext({ requestId }, async () => { const tracer = this._getTracer(); + const metricState: HttpMetricState = {}; + const metricStart = performance.now(); return await tracer.startActiveSpan( request.method, { @@ -1392,11 +1394,19 @@ export class FederationImpl ...options, span, tracer, + metricState, }); if (acceptsJsonLd(request)) { response.headers.set("Vary", "Accept"); } } catch (error) { + getFederationMetrics(this.meterProvider) + .recordHttpServerRequest( + request.method, + metricState.endpoint ?? "error", + getDurationMs(metricStart), + { routeTemplate: metricState.routeTemplate }, + ); span.setStatus({ code: SpanStatusCode.ERROR, message: `${error}`, @@ -1409,6 +1419,15 @@ export class FederationImpl ); throw error; } + getFederationMetrics(this.meterProvider).recordHttpServerRequest( + request.method, + metricState.endpoint ?? "error", + getDurationMs(metricStart), + { + statusCode: response.status, + routeTemplate: metricState.routeTemplate, + }, + ); if (span.isRecording()) { span.setAttribute( ATTR_HTTP_RESPONSE_STATUS_CODE, @@ -1453,14 +1472,24 @@ export class FederationImpl contextData, span, tracer, - }: FederationFetchOptions & { span: Span; tracer: Tracer }, + metricState, + }: FederationFetchOptions & { + span: Span; + tracer: Tracer; + metricState: HttpMetricState; + }, ): Promise { onNotFound ??= notFound; onNotAcceptable ??= notAcceptable; onUnauthorized ??= unauthorized; const url = new URL(request.url); const route = this.router.route(url.pathname); - if (route == null) return await onNotFound(request); + if (route == null) { + metricState.endpoint = "not_found"; + return await onNotFound(request); + } + metricState.routeTemplate = route.template; + metricState.endpoint = getEndpointCategory(route.name); span.updateName(`${request.method} ${route.template}`); let context = this.#createContext(request, contextData); const routeName = route.name.replace(/:.*$/, ""); @@ -1489,6 +1518,7 @@ export class FederationImpl // Routes that require JSON-LD Accepts header: if (request.method !== "POST" && !acceptsJsonLd(request)) { + metricState.endpoint = "not_acceptable"; return await onNotAcceptable(request); } switch (routeName) { @@ -1724,6 +1754,7 @@ export class FederationImpl }); } default: { + metricState.endpoint = "not_found"; const response = onNotFound(request); return response instanceof Promise ? await response : response; } @@ -1731,6 +1762,49 @@ export class FederationImpl } } +interface HttpMetricState { + endpoint?: string; + routeTemplate?: string; +} + +function getEndpointCategory(routeName: string): string { + if (routeName.startsWith("object:")) return "object"; + if ( + routeName.startsWith("collection:") || + routeName.startsWith("orderedCollection:") + ) { + return "collection"; + } + if (routeName.startsWith(ACTOR_ALIAS_PREFIX)) return "actor"; + switch (routeName) { + case "webfinger": + return "webfinger"; + case "nodeInfoJrd": + case "nodeInfo": + return "nodeinfo"; + case "actor": + return "actor"; + case "inbox": + return "inbox"; + case "sharedInbox": + return "shared_inbox"; + case "outbox": + return "outbox"; + case "following": + return "following"; + case "followers": + return "followers"; + case "liked": + return "liked"; + case "featured": + return "featured"; + case "featuredTags": + return "featured_tags"; + default: + return "not_found"; + } +} + interface ContextOptions { url: URL; federation: FederationImpl; From ce625ec671510bd7d89560b7db3bda520432bc38 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Thu, 7 May 2026 15:29:27 +0900 Subject: [PATCH 2/5] Recognize QUERY as a known HTTP method OpenTelemetry's HTTP semantic conventions list QUERY as part of the default known-method set for http.request.method. Without this, valid QUERY requests collapse into the _OTHER bucket and become indistinguishable from truly custom methods, skewing method-level request counts and latency. Add QUERY to KNOWN_HTTP_METHODS, document it in the normalized- method enumeration, and add a regression test asserting that a QUERY request is recorded with http.request.method=QUERY. https://github.com/fedify-dev/fedify/pull/757#discussion_r3199257965 Assisted-by: Claude Code:claude-opus-4-7 Assisted-by: Codex:gpt-5.5 --- docs/manual/opentelemetry.md | 4 ++-- packages/fedify/src/federation/metrics.ts | 1 + .../fedify/src/federation/middleware.test.ts | 21 +++++++++++++++++++ 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/docs/manual/opentelemetry.md b/docs/manual/opentelemetry.md index da2120786..e6288ba9c 100644 --- a/docs/manual/opentelemetry.md +++ b/docs/manual/opentelemetry.md @@ -330,8 +330,8 @@ Fedify records the following OpenTelemetry metrics: : `http.request.method` and `fedify.endpoint` are always present. `http.request.method` is normalized to one of the standard HTTP methods (`CONNECT`, `DELETE`, `GET`, `HEAD`, `OPTIONS`, `PATCH`, `POST`, `PUT`, - `TRACE`) or `_OTHER` for any other value, so that an arbitrary client - cannot inflate metric cardinality by sending custom methods. + `QUERY`, `TRACE`) or `_OTHER` for any other value, so that an arbitrary + client cannot inflate metric cardinality by sending custom methods. `http.response.status_code` is recorded when a `Response` is produced (success and non-2xx alike) and omitted when the request threw an exception before a response could be returned. `fedify.route.template` diff --git a/packages/fedify/src/federation/metrics.ts b/packages/fedify/src/federation/metrics.ts index 2fab40870..23d85d177 100644 --- a/packages/fedify/src/federation/metrics.ts +++ b/packages/fedify/src/federation/metrics.ts @@ -142,6 +142,7 @@ const KNOWN_HTTP_METHODS: ReadonlySet = new Set([ "PATCH", "POST", "PUT", + "QUERY", "TRACE", ]); diff --git a/packages/fedify/src/federation/middleware.test.ts b/packages/fedify/src/federation/middleware.test.ts index 71d765798..8306f345c 100644 --- a/packages/fedify/src/federation/middleware.test.ts +++ b/packages/fedify/src/federation/middleware.test.ts @@ -1739,6 +1739,27 @@ test("Federation.fetch() records HTTP server request metrics", async (t) => { }, ); + await t.step( + "preserves QUERY as a known HTTP method", + async () => { + const { federation, recorder } = createTestContext(); + const response = await federation.fetch( + new Request("https://example.com/users/alice", { + method: "QUERY", + headers: { "Accept": "application/activity+json" }, + }), + { contextData: undefined }, + ); + assert(response.status >= 100); + + const counts = recorder.getMeasurements( + "fedify.http.server.request.count", + ); + assertEquals(counts.length, 1); + assertEquals(counts[0].attributes["http.request.method"], "QUERY"); + }, + ); + await t.step( "uses the global meter provider when none is configured", async () => { From f2308269d3f54257fd5e4f05fe24f120cb522bc7 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Thu, 7 May 2026 15:33:42 +0900 Subject: [PATCH 3/5] Add explicit bucket boundaries to HTTP server duration Without advice.explicitBucketBoundaries, the SDK falls back to its default boundary set, which doesn't align with the latency distribution Fedify expects from inbound HTTP requests and makes P95 less precise than it could be. Mirror the OpenTelemetry HTTP server semantic-conventions recommended buckets, converted from seconds to milliseconds to match the histogram's unit: 5, 10, 25, 50, 75, 100, 250, 500, 750, 1000, 2500, 5000, 7500, 10000 The activitypub.delivery.duration and activitypub.inbox.processing_duration histograms are intentionally left untouched: they have different latency profiles from inbound HTTP requests and are out of scope for this PR. https://github.com/fedify-dev/fedify/pull/757#discussion_r3199283311 Assisted-by: Claude Code:claude-opus-4-7 Assisted-by: Codex:gpt-5.5 --- packages/fedify/src/federation/metrics.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/packages/fedify/src/federation/metrics.ts b/packages/fedify/src/federation/metrics.ts index 23d85d177..2decbc9c9 100644 --- a/packages/fedify/src/federation/metrics.ts +++ b/packages/fedify/src/federation/metrics.ts @@ -62,6 +62,26 @@ class FederationMetrics { { description: "Duration of HTTP requests handled by Federation.fetch().", unit: "ms", + advice: { + // Mirror the OpenTelemetry HTTP server semantic-conventions + // recommended buckets, expressed in milliseconds. + explicitBucketBoundaries: [ + 5, + 10, + 25, + 50, + 75, + 100, + 250, + 500, + 750, + 1000, + 2500, + 5000, + 7500, + 10000, + ], + }, }, ); } From ffc7fd3b97094bf44b925048b91c2e2e977ca4b0 Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Thu, 7 May 2026 15:38:21 +0900 Subject: [PATCH 4/5] Type fedify.endpoint as a literal union HttpMetricState.endpoint and getEndpointCategory() previously used the unconstrained string type, so the type system gave no guidance when the metric attribute enum drifted from the documented set. Introduce a FedifyEndpoint string-literal union covering the 16 documented values (webfinger, nodeinfo, actor, inbox, shared_inbox, outbox, object, following, followers, liked, featured, featured_tags, collection, not_found, not_acceptable, error) and use it for both the metric-state field and the helper's return type. Future drift between the documented enum and the recorded attribute will now surface at compile time. https://github.com/fedify-dev/fedify/pull/757#discussion_r3199283341 Assisted-by: Claude Code:claude-opus-4-7 Assisted-by: Codex:gpt-5.5 --- packages/fedify/src/federation/middleware.ts | 22 ++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/fedify/src/federation/middleware.ts b/packages/fedify/src/federation/middleware.ts index f0e114edc..f29e3a84c 100644 --- a/packages/fedify/src/federation/middleware.ts +++ b/packages/fedify/src/federation/middleware.ts @@ -1762,12 +1762,30 @@ export class FederationImpl } } +type FedifyEndpoint = + | "webfinger" + | "nodeinfo" + | "actor" + | "inbox" + | "shared_inbox" + | "outbox" + | "object" + | "following" + | "followers" + | "liked" + | "featured" + | "featured_tags" + | "collection" + | "not_found" + | "not_acceptable" + | "error"; + interface HttpMetricState { - endpoint?: string; + endpoint?: FedifyEndpoint; routeTemplate?: string; } -function getEndpointCategory(routeName: string): string { +function getEndpointCategory(routeName: string): FedifyEndpoint { if (routeName.startsWith("object:")) return "object"; if ( routeName.startsWith("collection:") || From 23a649140b323b1f5200da2b703e4c75d701083f Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Thu, 7 May 2026 16:21:32 +0900 Subject: [PATCH 5/5] Cover the user-defined collection collapse in tests The PR documents that user-defined collection dispatchers (setCollectionDispatcher) collapse to fedify.endpoint=collection regardless of the dispatcher's name, but createTestContext() never registered one, so a regression that emitted the raw dispatcher name instead of "collection" would slip through. Register a custom collection dispatcher at /users/{identifier}/custom/{id} in createTestContext() and add a test step asserting that a GET to that path records fedify.endpoint=collection and the parameterized fedify.route.template. https://github.com/fedify-dev/fedify/pull/757#discussion_r3199415411 Assisted-by: Claude Code:claude-opus-4-7 Assisted-by: Codex:gpt-5.5 --- .../fedify/src/federation/middleware.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/fedify/src/federation/middleware.test.ts b/packages/fedify/src/federation/middleware.test.ts index 8306f345c..7de8556cc 100644 --- a/packages/fedify/src/federation/middleware.test.ts +++ b/packages/fedify/src/federation/middleware.test.ts @@ -1490,6 +1490,13 @@ test("Federation.fetch() records HTTP server request metrics", async (t) => { () => ({ items: [] }), ); + federation.setCollectionDispatcher( + "custom-collection", + vocab.Object, + "/users/{identifier}/custom/{id}", + () => ({ items: [] }), + ); + federation.setInboxListeners("/users/{identifier}/inbox", "/inbox"); return { federation, recorder }; @@ -1666,6 +1673,31 @@ test("Federation.fetch() records HTTP server request metrics", async (t) => { }, ); + await t.step( + "collapses user-defined collection dispatchers to endpoint=collection", + async () => { + const { federation, recorder } = createTestContext(); + const response = await federation.fetch( + new Request("https://example.com/users/alice/custom/1", { + method: "GET", + headers: { "Accept": "application/activity+json" }, + }), + { contextData: undefined }, + ); + assertEquals(response.status, 200); + + const counts = recorder.getMeasurements( + "fedify.http.server.request.count", + ); + assertEquals(counts.length, 1); + assertEquals(counts[0].attributes["fedify.endpoint"], "collection"); + assertEquals( + counts[0].attributes["fedify.route.template"], + "/users/{identifier}/custom/{id}", + ); + }, + ); + await t.step("records followers as endpoint=followers", async () => { const { federation, recorder } = createTestContext(); const response = await federation.fetch(