Unverified Commit 006c410a authored by Hong Minhee's avatar Hong Minhee
Browse files

OpenTelemetry pilot application

parent 52276143
Loading
Loading
Loading
Loading
+10 −0
Original line number Diff line number Diff line
@@ -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.

+2 −0
Original line number Diff line number Diff line
@@ -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",
+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,
@@ -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;
}

/**
@@ -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;
@@ -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(
@@ -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 = {
@@ -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;
        },
      );
    });
  }

@@ -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) {
+6 −0
Original line number Diff line number Diff line
@@ -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);
@@ -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" },
  });
});
+26 −1
Original line number Diff line number Diff line
@@ -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)).
@@ -20,6 +41,7 @@ export interface RouterOptions {
export class Router {
  #router: InnerRouter;
  #templates: Record<string, Template>;
  #templateStrings: Record<string, string>;
  #trailingSlashInsensitive: boolean;

  /**
@@ -29,6 +51,7 @@ export class Router {
  constructor(options: RouterOptions = {}) {
    this.#router = new InnerRouter();
    this.#templates = {};
    this.#templateStrings = {};
    this.#trailingSlashInsensitive = options.trailingSlashInsensitive ?? false;
  }

@@ -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));
  }

@@ -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;
@@ -72,6 +96,7 @@ export class Router {
    }
    return {
      name: match.matchValue,
      template: this.#templateStrings[match.matchValue],
      values: match.params,
    };
  }