All files / lib/logging otel.ts

100% Statements 178/178
100% Branches 18/18
100% Functions 6/6
100% Lines 178/178

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 1791x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 982x 982x 982x 982x 982x 982x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1526x 1526x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 488x 488x 488x 488x 1987x 345x 345x 1987x 149x 85x 149x 63x 63x 64x 1x 1x 149x 149x 1987x 82x 82x 82x 1987x 1493x 488x 488x 1x 1x 1x 1x 1x 1x 1x 1x 692x 692x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x  
"use strict";
/**
 * OpenTelemetry representation of operational log records.
 *
 * Operational records follow the OpenTelemetry Logs Data Model: one JSON
 * object per line with the data model's field names (`Timestamp`,
 * `SeverityText`, `SeverityNumber`, `EventName`, `Body`, `Resource`,
 * `InstrumentationScope`, `Attributes`).
 *
 * Attribute names follow the OpenTelemetry semantic conventions
 * ({@link OTEL_SEMCONV_VERSION}), including the Model Context Protocol
 * conventions. Attributes the conventions do not define are prefixed with
 * the `oag_mcp.` namespace, as the conventions recommend.
 *
 * Call sites log camelCase fields; {@link toOtelAttributes} maps them.
 *
 * @module logging/otel
 */
import type pino from "pino";
 
/** Version of the OpenTelemetry semantic conventions followed. */
export const OTEL_SEMCONV_VERSION = "1.44.0";
 
/** Namespace for attributes that the semantic conventions do not define. */
export const OAG_MCP_NAMESPACE = "oag_mcp";
 
/** `SeverityNumber` of each pino level (OpenTelemetry Logs Data Model). */
export const SEVERITY_NUMBER: Readonly<Record<pino.Level, number>> = {
  trace: 1,
  debug: 5,
  info: 9,
  warn: 13,
  error: 17,
  fatal: 21,
};
 
/**
 * Fields whose OpenTelemetry semantic convention attribute differs from the
 * default `oag_mcp.<snake_case>` name.
 */
const ATTRIBUTES: Readonly<Record<string, string>> = {
  // Correlation identifiers specific to oag-mcp.
  requestId: "oag_mcp.request.id",
  executionId: "oag_mcp.execution.id",
  prepareId: "oag_mcp.prepare.id",
  // The authenticated caller.
  principalId: "enduser.id",
  clientAddress: "client.address",
  clientPort: "client.port",
  // Model Context Protocol.
  mcpSessionId: "mcp.session.id",
  jsonrpcRequestId: "jsonrpc.request.id",
  toolName: "gen_ai.tool.name",
  // HTTP calls to the upstream API.
  httpMethod: "http.request.method",
  status: "http.response.status_code",
  pathTemplate: "url.template",
  responseBytes: "http.response.body.size",
  // The address the HTTP transport listens on.
  host: "server.address",
  port: "server.port",
  // Error classification.
  error: "error.type",
  errorCode: "error.type",
  errorType: "error.type",
};
 
/**
 * Convert a camelCase field name to snake_case.
 *
 * @param {String} name - field name, e.g. "startupDurationMs"
 * @return {String} snake_case name, e.g. "startup_duration_ms"
 * @private
 */
function _snake(name: string): string {
  return name
    .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
    .replace(/([A-Z])([A-Z][a-z])/g, "$1_$2")
    .toLowerCase();
}
 
/**
 * The attribute name for a field: its semantic convention attribute, or
 * `oag_mcp.<snake_case>`.
 *
 * @param {String} field - camelCase field name
 * @return {String} attribute name
 */
export function otelAttributeName(field: string): string {
  return ATTRIBUTES[field] ?? `${OAG_MCP_NAMESPACE}.${_snake(field)}`;
}
 
/**
 * Map logged fields to OpenTelemetry attributes.
 *
 * - `transport` becomes `network.transport` (`pipe` for stdio, `tcp` for
 *   Streamable HTTP, with `network.protocol.name: http`), as the MCP
 *   conventions describe.
 * - `toolName` also sets `mcp.method.name: tools/call` and
 *   `gen_ai.operation.name: execute_tool`.
 * - Undefined values are dropped.
 *
 * @param {Object} fields - camelCase fields, already redacted and bounded
 * @return {Object} attributes keyed by attribute name
 */
export function toOtelAttributes(
  fields: Record<string, unknown>,
): Record<string, unknown> {
  const out: Record<string, unknown> = {};
  for (const [key, value] of Object.entries(fields)) {
    if (value === undefined) {
      continue;
    }
    if (key === "transport") {
      if (value === "stdio") {
        out["network.transport"] = "pipe";
      } else if (value === "streamable-http") {
        out["network.transport"] = "tcp";
        out["network.protocol.name"] = "http";
      } else {
        out[otelAttributeName(key)] = value;
      }
      continue;
    }
    if (key === "toolName") {
      out["mcp.method.name"] = "tools/call";
      out["gen_ai.operation.name"] = "execute_tool";
    }
    out[otelAttributeName(key)] = value;
  }
  return out;
}
 
/**
 * `Timestamp` of a record: nanoseconds since the Unix epoch, as a string.
 *
 * @param {Number} [ms] - milliseconds since the epoch (default: now)
 * @return {String} nanoseconds since the epoch
 */
export function otelTimestamp(ms: number = Date.now()): string {
  return `${ms}000000`;
}
 
/** The `Resource` and `InstrumentationScope` of every operational record. */
export interface OtelSource {
  /** Resource attributes, e.g. `service.name`. */
  resource: Record<string, unknown>;
  /** Instrumentation scope name and version. */
  scope: { Name: string; Version?: string };
}
 
/**
 * Build a complete operational record outside the logger, for the few
 * reports written to stderr directly when a destination fails.
 *
 * @param {String} level - pino level
 * @param {String} event - event name
 * @param {Object} fields - camelCase fields (no secrets)
 * @param {Object} source - resource and scope
 * @return {Object} record
 */
export function otelRecord(
  level: pino.Level,
  event: string,
  fields: Record<string, unknown>,
  source: OtelSource,
): Record<string, unknown> {
  return {
    Timestamp: otelTimestamp(),
    SeverityText: level.toUpperCase(),
    SeverityNumber: SEVERITY_NUMBER[level],
    Resource: source.resource,
    InstrumentationScope: source.scope,
    EventName: event,
    Attributes: toOtelAttributes(fields),
    Body: event,
  };
}