Loading CHANGES.md +10 −0 Original line number Diff line number Diff line Loading @@ -51,6 +51,16 @@ To be released. - Added `FetchKeyOptions` interface. - Added `FetchKeyResult` interface. - The `Router` now provide the matched route's URI template besides the name. - The return type of `Router.route()` method became `RouterRouteResult | null` (was `{ name: string; values: Record<string, string> } | null`). - Added `RouterRouteResult` interface. - Fedify now supports OpenTelemetry for tracing. - Added `CreateFederationOptions.tracerProvider` option. - The scaffold project generated by `fedify init` command now enables tracing data into log messages. Loading src/deno.json +2 −0 Original line number Diff line number Diff line Loading @@ -31,6 +31,8 @@ "@hongminhee/aitertools": "jsr:@hongminhee/aitertools@^0.6.0", "@hugoalh/http-header-link": "jsr:@hugoalh/http-header-link@^1.0.2", "@logtape/logtape": "jsr:@logtape/logtape@^0.8.0", "@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0", "@opentelemetry/semantic-conventions": "npm:@opentelemetry/semantic-conventions@^1.27.0", "@phensley/language-tag": "npm:@phensley/language-tag@^1.9.0", "@std/assert": "jsr:@std/assert@^0.226.0", "@std/async": "jsr:@std/async@^1.0.5", Loading src/federation/middleware.ts +91 −19 Original line number Diff line number Diff line import { getLogger, withContext } from "@logtape/logtape"; import { type Span, SpanKind, SpanStatusCode, trace, type TracerProvider, } from "@opentelemetry/api"; import { ATTR_HTTP_REQUEST_HEADER, ATTR_HTTP_REQUEST_METHOD, ATTR_HTTP_RESPONSE_HEADER, ATTR_HTTP_RESPONSE_STATUS_CODE, ATTR_URL_FULL, } from "@opentelemetry/semantic-conventions"; import metadata from "../deno.json" with { type: "json" }; import { handleNodeInfo, handleNodeInfoJrd } from "../nodeinfo/handler.ts"; import { type AuthenticatedDocumentLoaderFactory, Loading Loading @@ -221,6 +236,13 @@ export interface CreateFederationOptions { * @since 0.12.0 */ trailingSlashInsensitive?: boolean; /** * The OpenTelemetry tracer provider for tracing operations. If not provided, * the default global tracer provider is used. * @since 1.3.0 */ tracerProvider?: TracerProvider; } /** Loading Loading @@ -349,6 +371,7 @@ export class FederationImpl<TContextData> implements Federation<TContextData> { skipSignatureVerification: boolean; outboxRetryPolicy: RetryPolicy; inboxRetryPolicy: RetryPolicy; tracerProvider: TracerProvider; constructor(options: CreateFederationOptions) { this.kv = options.kv; Loading Loading @@ -420,6 +443,11 @@ export class FederationImpl<TContextData> implements Federation<TContextData> { createExponentialBackoffPolicy(); this.inboxRetryPolicy = options.inboxRetryPolicy ?? createExponentialBackoffPolicy(); this.tracerProvider = options.tracerProvider ?? trace.getTracerProvider(); } #getTracer() { return this.tracerProvider.getTracer(metadata.name, metadata.version); } async #startQueue( Loading Loading @@ -1859,8 +1887,51 @@ export class FederationImpl<TContextData> implements Federation<TContextData> { ): Promise<Response> { const requestId = getRequestId(request); return withContext({ requestId }, async () => { const response = await this.#fetch(request, options); const tracer = this.#getTracer(); return await tracer.startActiveSpan( request.method, { kind: SpanKind.SERVER, attributes: { [ATTR_HTTP_REQUEST_METHOD]: request.method, [ATTR_URL_FULL]: request.url, }, }, async (span) => { const logger = getLogger(["fedify", "federation", "http"]); if (span.isRecording()) { for (const [k, v] of request.headers) { span.setAttribute(ATTR_HTTP_REQUEST_HEADER(k), [v]); } } let response: Response; try { response = await this.#fetch(request, { ...options, span }); } catch (error) { span.setStatus({ code: SpanStatusCode.ERROR, message: `${error}`, }); span.end(); logger.error( "An error occurred while serving request {method} {url}: {error}", { method: request.method, url: request.url, error }, ); throw error; } if (span.isRecording()) { span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, response.status); for (const [k, v] of response.headers) { span.setAttribute(ATTR_HTTP_RESPONSE_HEADER(k), [v]); } span.setStatus({ code: response.status >= 500 ? SpanStatusCode.ERROR : SpanStatusCode.UNSET, message: response.statusText, }); } span.end(); const url = new URL(request.url); const logTpl = "{method} {path}: {status}"; const values = { Loading @@ -1873,6 +1944,8 @@ export class FederationImpl<TContextData> implements Federation<TContextData> { else if (response.status >= 400) logger.warn(logTpl, values); else logger.info(logTpl, values); return response; }, ); }); } Loading @@ -1883,17 +1956,16 @@ export class FederationImpl<TContextData> implements Federation<TContextData> { onNotAcceptable, onUnauthorized, contextData, }: FederationFetchOptions<TContextData>, span, }: FederationFetchOptions<TContextData> & { span: Span }, ): Promise<Response> { onNotFound ??= notFound; onNotAcceptable ??= notAcceptable; onUnauthorized ??= unauthorized; const url = new URL(request.url); const route = this.router.route(url.pathname); if (route == null) { const response = onNotFound(request); return response instanceof Promise ? await response : response; } if (route == null) return await onNotFound(request); span.updateName(`${request.method} ${route.template}`); let context = this.#createContext(request, contextData); const routeName = route.name.replace(/:.*$/, ""); switch (routeName) { Loading src/federation/router.test.ts +6 −0 Original line number Diff line number Diff line Loading @@ -31,11 +31,13 @@ test("Router.route()", () => { let router = setUp(); assertEquals(router.route("/users/alice"), { name: "user", template: "/users/{name}", values: { name: "alice" }, }); assertEquals(router.route("/users/bob/"), null); assertEquals(router.route("/users/alice/posts/123"), { name: "post", template: "/users/{name}/posts/{postId}", values: { name: "alice", postId: "123" }, }); assertEquals(router.route("/users/bob/posts/456/"), null); Loading @@ -43,18 +45,22 @@ test("Router.route()", () => { router = setUp({ trailingSlashInsensitive: true }); assertEquals(router.route("/users/alice"), { name: "user", template: "/users/{name}", values: { name: "alice" }, }); assertEquals(router.route("/users/bob/"), { name: "user", template: "/users/{name}", values: { name: "bob" }, }); assertEquals(router.route("/users/alice/posts/123"), { name: "post", template: "/users/{name}/posts/{postId}/", values: { name: "alice", postId: "123" }, }); assertEquals(router.route("/users/bob/posts/456/"), { name: "post", template: "/users/{name}/posts/{postId}/", values: { name: "bob", postId: "456" }, }); }); Loading src/federation/router.ts +26 −1 Original line number Diff line number Diff line Loading @@ -13,6 +13,27 @@ export interface RouterOptions { trailingSlashInsensitive?: boolean; } /** * The result of {@link Router.route} method. * @since 1.3.0 */ export interface RouterRouteResult { /** * The matched route name. */ name: string; /** * The URL template of the matched route. */ template: string; /** * The values extracted from the URL. */ values: Record<string, string>; } /** * URL router and constructor based on URI Template * ([RFC 6570](https://tools.ietf.org/html/rfc6570)). Loading @@ -20,6 +41,7 @@ export interface RouterOptions { export class Router { #router: InnerRouter; #templates: Record<string, Template>; #templateStrings: Record<string, string>; #trailingSlashInsensitive: boolean; /** Loading @@ -29,6 +51,7 @@ export class Router { constructor(options: RouterOptions = {}) { this.#router = new InnerRouter(); this.#templates = {}; this.#templateStrings = {}; this.#trailingSlashInsensitive = options.trailingSlashInsensitive ?? false; } Loading @@ -53,6 +76,7 @@ export class Router { } const rule = this.#router.addTemplate(template, {}, name); this.#templates[name] = parseTemplate(template); this.#templateStrings[name] = template; return new Set(rule.variables.map((v: { varname: string }) => v.varname)); } Loading @@ -62,7 +86,7 @@ export class Router { * @returns The name of the path and its values, if any match. Otherwise, * `null`. */ route(url: string): { name: string; values: Record<string, string> } | null { route(url: string): RouterRouteResult | null { let match = this.#router.resolveURI(url); if (match == null) { if (!this.#trailingSlashInsensitive) return null; Loading @@ -72,6 +96,7 @@ export class Router { } return { name: match.matchValue, template: this.#templateStrings[match.matchValue], values: match.params, }; } Loading Loading
CHANGES.md +10 −0 Original line number Diff line number Diff line Loading @@ -51,6 +51,16 @@ To be released. - Added `FetchKeyOptions` interface. - Added `FetchKeyResult` interface. - The `Router` now provide the matched route's URI template besides the name. - The return type of `Router.route()` method became `RouterRouteResult | null` (was `{ name: string; values: Record<string, string> } | null`). - Added `RouterRouteResult` interface. - Fedify now supports OpenTelemetry for tracing. - Added `CreateFederationOptions.tracerProvider` option. - The scaffold project generated by `fedify init` command now enables tracing data into log messages. Loading
src/deno.json +2 −0 Original line number Diff line number Diff line Loading @@ -31,6 +31,8 @@ "@hongminhee/aitertools": "jsr:@hongminhee/aitertools@^0.6.0", "@hugoalh/http-header-link": "jsr:@hugoalh/http-header-link@^1.0.2", "@logtape/logtape": "jsr:@logtape/logtape@^0.8.0", "@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0", "@opentelemetry/semantic-conventions": "npm:@opentelemetry/semantic-conventions@^1.27.0", "@phensley/language-tag": "npm:@phensley/language-tag@^1.9.0", "@std/assert": "jsr:@std/assert@^0.226.0", "@std/async": "jsr:@std/async@^1.0.5", Loading
src/federation/middleware.ts +91 −19 Original line number Diff line number Diff line import { getLogger, withContext } from "@logtape/logtape"; import { type Span, SpanKind, SpanStatusCode, trace, type TracerProvider, } from "@opentelemetry/api"; import { ATTR_HTTP_REQUEST_HEADER, ATTR_HTTP_REQUEST_METHOD, ATTR_HTTP_RESPONSE_HEADER, ATTR_HTTP_RESPONSE_STATUS_CODE, ATTR_URL_FULL, } from "@opentelemetry/semantic-conventions"; import metadata from "../deno.json" with { type: "json" }; import { handleNodeInfo, handleNodeInfoJrd } from "../nodeinfo/handler.ts"; import { type AuthenticatedDocumentLoaderFactory, Loading Loading @@ -221,6 +236,13 @@ export interface CreateFederationOptions { * @since 0.12.0 */ trailingSlashInsensitive?: boolean; /** * The OpenTelemetry tracer provider for tracing operations. If not provided, * the default global tracer provider is used. * @since 1.3.0 */ tracerProvider?: TracerProvider; } /** Loading Loading @@ -349,6 +371,7 @@ export class FederationImpl<TContextData> implements Federation<TContextData> { skipSignatureVerification: boolean; outboxRetryPolicy: RetryPolicy; inboxRetryPolicy: RetryPolicy; tracerProvider: TracerProvider; constructor(options: CreateFederationOptions) { this.kv = options.kv; Loading Loading @@ -420,6 +443,11 @@ export class FederationImpl<TContextData> implements Federation<TContextData> { createExponentialBackoffPolicy(); this.inboxRetryPolicy = options.inboxRetryPolicy ?? createExponentialBackoffPolicy(); this.tracerProvider = options.tracerProvider ?? trace.getTracerProvider(); } #getTracer() { return this.tracerProvider.getTracer(metadata.name, metadata.version); } async #startQueue( Loading Loading @@ -1859,8 +1887,51 @@ export class FederationImpl<TContextData> implements Federation<TContextData> { ): Promise<Response> { const requestId = getRequestId(request); return withContext({ requestId }, async () => { const response = await this.#fetch(request, options); const tracer = this.#getTracer(); return await tracer.startActiveSpan( request.method, { kind: SpanKind.SERVER, attributes: { [ATTR_HTTP_REQUEST_METHOD]: request.method, [ATTR_URL_FULL]: request.url, }, }, async (span) => { const logger = getLogger(["fedify", "federation", "http"]); if (span.isRecording()) { for (const [k, v] of request.headers) { span.setAttribute(ATTR_HTTP_REQUEST_HEADER(k), [v]); } } let response: Response; try { response = await this.#fetch(request, { ...options, span }); } catch (error) { span.setStatus({ code: SpanStatusCode.ERROR, message: `${error}`, }); span.end(); logger.error( "An error occurred while serving request {method} {url}: {error}", { method: request.method, url: request.url, error }, ); throw error; } if (span.isRecording()) { span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, response.status); for (const [k, v] of response.headers) { span.setAttribute(ATTR_HTTP_RESPONSE_HEADER(k), [v]); } span.setStatus({ code: response.status >= 500 ? SpanStatusCode.ERROR : SpanStatusCode.UNSET, message: response.statusText, }); } span.end(); const url = new URL(request.url); const logTpl = "{method} {path}: {status}"; const values = { Loading @@ -1873,6 +1944,8 @@ export class FederationImpl<TContextData> implements Federation<TContextData> { else if (response.status >= 400) logger.warn(logTpl, values); else logger.info(logTpl, values); return response; }, ); }); } Loading @@ -1883,17 +1956,16 @@ export class FederationImpl<TContextData> implements Federation<TContextData> { onNotAcceptable, onUnauthorized, contextData, }: FederationFetchOptions<TContextData>, span, }: FederationFetchOptions<TContextData> & { span: Span }, ): Promise<Response> { onNotFound ??= notFound; onNotAcceptable ??= notAcceptable; onUnauthorized ??= unauthorized; const url = new URL(request.url); const route = this.router.route(url.pathname); if (route == null) { const response = onNotFound(request); return response instanceof Promise ? await response : response; } if (route == null) return await onNotFound(request); span.updateName(`${request.method} ${route.template}`); let context = this.#createContext(request, contextData); const routeName = route.name.replace(/:.*$/, ""); switch (routeName) { Loading
src/federation/router.test.ts +6 −0 Original line number Diff line number Diff line Loading @@ -31,11 +31,13 @@ test("Router.route()", () => { let router = setUp(); assertEquals(router.route("/users/alice"), { name: "user", template: "/users/{name}", values: { name: "alice" }, }); assertEquals(router.route("/users/bob/"), null); assertEquals(router.route("/users/alice/posts/123"), { name: "post", template: "/users/{name}/posts/{postId}", values: { name: "alice", postId: "123" }, }); assertEquals(router.route("/users/bob/posts/456/"), null); Loading @@ -43,18 +45,22 @@ test("Router.route()", () => { router = setUp({ trailingSlashInsensitive: true }); assertEquals(router.route("/users/alice"), { name: "user", template: "/users/{name}", values: { name: "alice" }, }); assertEquals(router.route("/users/bob/"), { name: "user", template: "/users/{name}", values: { name: "bob" }, }); assertEquals(router.route("/users/alice/posts/123"), { name: "post", template: "/users/{name}/posts/{postId}/", values: { name: "alice", postId: "123" }, }); assertEquals(router.route("/users/bob/posts/456/"), { name: "post", template: "/users/{name}/posts/{postId}/", values: { name: "bob", postId: "456" }, }); }); Loading
src/federation/router.ts +26 −1 Original line number Diff line number Diff line Loading @@ -13,6 +13,27 @@ export interface RouterOptions { trailingSlashInsensitive?: boolean; } /** * The result of {@link Router.route} method. * @since 1.3.0 */ export interface RouterRouteResult { /** * The matched route name. */ name: string; /** * The URL template of the matched route. */ template: string; /** * The values extracted from the URL. */ values: Record<string, string>; } /** * URL router and constructor based on URI Template * ([RFC 6570](https://tools.ietf.org/html/rfc6570)). Loading @@ -20,6 +41,7 @@ export interface RouterOptions { export class Router { #router: InnerRouter; #templates: Record<string, Template>; #templateStrings: Record<string, string>; #trailingSlashInsensitive: boolean; /** Loading @@ -29,6 +51,7 @@ export class Router { constructor(options: RouterOptions = {}) { this.#router = new InnerRouter(); this.#templates = {}; this.#templateStrings = {}; this.#trailingSlashInsensitive = options.trailingSlashInsensitive ?? false; } Loading @@ -53,6 +76,7 @@ export class Router { } const rule = this.#router.addTemplate(template, {}, name); this.#templates[name] = parseTemplate(template); this.#templateStrings[name] = template; return new Set(rule.variables.map((v: { varname: string }) => v.varname)); } Loading @@ -62,7 +86,7 @@ export class Router { * @returns The name of the path and its values, if any match. Otherwise, * `null`. */ route(url: string): { name: string; values: Record<string, string> } | null { route(url: string): RouterRouteResult | null { let match = this.#router.resolveURI(url); if (match == null) { if (!this.#trailingSlashInsensitive) return null; Loading @@ -72,6 +96,7 @@ export class Router { } return { name: match.matchValue, template: this.#templateStrings[match.matchValue], values: match.params, }; } Loading