span.js 4.2 KB
/**
 * trace 面(span 侧)—— `startSpan → Span → CompletedSpan` 与默认结构化日志 sink。
 *
 * 属性对齐 OpenTelemetry `gen_ai.*` 语义约定:`@drama/llm` base.js 传入的中性键
 * (`provider`/`model`/usage 分量)在本包唯一一处(`mapAttrKey`/`mapEventAttrs`)
 * 映射为 gen_ai 语义键;下游两个 sink 消费同一 `CompletedSpan`,不再判类型。
 *
 * 边界纪律:时钟 `now`、`logger`、`sink` 全部注入;包内不读 env、不建连接、不写盘。
 */

/**
 * @typedef {import('./logger.js').Logger} Logger
 */

/**
 * @typedef {object} SpanEvent
 * @property {string} name
 * @property {Record<string, any>} attributes
 */

/**
 * `end()` 写给 sink 的完结产物(两个 sink 消费的唯一结构)。
 * @typedef {object} CompletedSpan
 * @property {string} name
 * @property {number} durationMs                        end 时刻 − startSpan 时刻。
 * @property {number} startUnixNano
 * @property {number} endUnixNano
 * @property {Record<string, any>} attributes           已映射为 gen_ai.* 语义键。
 * @property {Array<SpanEvent>} events
 */

/**
 * 上报目标端口 —— 唯一方法 `write`。新增上报目标 = 新增一个 sink 工厂,不改本文件。
 * @typedef {object} Sink
 * @property {(span: CompletedSpan) => void} write
 */

/**
 * @typedef {object} Span
 * @property {(k: string, v: any) => void} setAttr
 * @property {(n: string, d?: object) => void} addEvent
 * @property {() => void} end
 */

/** `setAttr` 键 → gen_ai 语义属性键。 */
const ATTR_KEY_MAP = {
  provider: 'gen_ai.system',
  model: 'gen_ai.request.model',
}

/** `usage` 事件属性键 → gen_ai 语义属性键。 */
const USAGE_KEY_MAP = {
  prompt_tokens: 'gen_ai.usage.input_tokens',
  completion_tokens: 'gen_ai.usage.output_tokens',
  total_tokens: 'gen_ai.usage.total_tokens',
  cached_tokens: 'gen_ai.usage.cached_tokens',
  cache_write_tokens: 'gen_ai.usage.cache_write_tokens',
  reasoning_tokens: 'gen_ai.usage.reasoning_tokens',
}

/**
 * setAttr 键映射:命中语义表返回 gen_ai 键,未命中原样返回。
 * @param {string} k
 * @returns {string}
 */
function mapAttrKey(k) {
  return ATTR_KEY_MAP[k] ?? k
}

/**
 * 事件属性键映射:仅 `usage` 事件按 `USAGE_KEY_MAP` 重命名(只映射表内出现的键),
 * 其余事件(`content`/`error`/`aborted`)属性原样透传。
 * @param {string} name
 * @param {Record<string, any>} data
 * @returns {Record<string, any>}
 */
function mapEventAttrs(name, data) {
  if (name !== 'usage') return data
  /** @type {Record<string, any>} */
  const out = {}
  for (const [k, val] of Object.entries(data)) {
    out[USAGE_KEY_MAP[k] ?? k] = val
  }
  return out
}

/**
 * 开一个 span。返回值恒为 `{ setAttr, addEvent, end }` 三方法对象。
 * `end()` 恰调一次 `sink.write(CompletedSpan)`。
 *
 * @param {string} name
 * @param {object} deps
 * @param {Sink} deps.sink                        必注入;缺 tracePort 的降级由 @drama/llm noopTracePort 承担。
 * @param {() => number} [deps.now=Date.now]      注入时钟(毫秒),测试可推进。
 * @returns {Span}
 */
export function startSpan(name, { sink, now = Date.now }) {
  const startMs = now()
  /** @type {Record<string, any>} */
  const attributes = {}
  /** @type {Array<SpanEvent>} */
  const events = []
  return {
    setAttr(k, v) {
      attributes[mapAttrKey(k)] = v
    },
    addEvent(n, d) {
      events.push({ name: n, attributes: mapEventAttrs(n, d ?? {}) })
    },
    end() {
      const endMs = now()
      sink.write({
        name,
        durationMs: endMs - startMs,
        startUnixNano: startMs * 1e6,
        endUnixNano: endMs * 1e6,
        attributes,
        events,
      })
    },
  }
}

/**
 * 默认 sink:把一个完结 span 写成 `createLogger` 的**一条**结构化 JSON 行。
 * `logger` 为 `createLogger(...)` 实例(注入,本包不 new)。
 *
 * @param {object} deps
 * @param {Logger} deps.logger
 * @returns {Sink}
 */
export function createLogSink({ logger }) {
  return {
    write(span) {
      logger.info('llm.trace', {
        span: span.name,
        duration_ms: span.durationMs,
        ...span.attributes,
        events: span.events,
      })
    },
  }
}