{"version":3,"sources":["../../src/services/LoggingService.ts"],"sourcesContent":["import {\n  CoolhandCallData,\n  CoolhandLogPayload,\n  CoolhandLogResponse,\n  CoolhandMatchedPattern,\n  GetLogContentOptions,\n  GetLogContentSliceOptions,\n  GetLogContentSearchOptions,\n  LlmRequestLogContent,\n  LlmRequestLogContentFull,\n  LlmRequestLogContentSearchResult,\n  LlmRequestLogSummary,\n  Pagination,\n  SearchLogsParams,\n  SearchLogsResponse\n} from '../types';\nimport { CollectionMethod } from '../utils/collector.js';\nimport { BaseService, BaseServiceConfig } from './BaseService.js';\n\n// Treats a missing or non-integer header value (empty string, decimals, hex, negative, garbage\n// text) as absent, falling back rather than propagating a nonsensical value into a response type\n// callers assume is always a valid non-negative integer. Deliberately stricter than `Number(...)`\n// — e.g. `Number('')` is `0`, which would otherwise silently look like a legitimate zero count.\nfunction parseHeaderInt(value: string | null, fallback: number): number {\n  if (value === null) {\n    return fallback;\n  }\n  const trimmed = value.trim();\n  return /^\\d+$/.test(trimmed) ? Number(trimmed) : fallback;\n}\n\nexport interface LoggingServiceConfig extends BaseServiceConfig {}\n\nexport class LoggingService extends BaseService {\n  constructor(config: LoggingServiceConfig) {\n    super(config, '/api/v2/llm_request_logs');\n  }\n\n  public async logRequestToAPI(\n    callData: CoolhandCallData,\n    matchedPattern?: CoolhandMatchedPattern,\n    collectionMethod?: CollectionMethod,\n    collector?: string,\n    metadata?: Record<string, unknown>\n  ): Promise<CoolhandLogResponse | null> {\n    // An explicit collector string (e.g. from coolhand-cli) overrides the SDK-derived one.\n    const logData = collector !== undefined\n      ? { raw_request: callData, collector, ...(metadata && { metadata }) }\n      : { ...this.addCollectorToData({ raw_request: callData }, collectionMethod), ...(metadata && { metadata }) };\n\n    const payload: CoolhandLogPayload = {\n      llm_request_log: logData\n    };\n\n    this.logRequestInfo(callData, matchedPattern);\n\n    const result = await this.sendRequest<CoolhandLogResponse>(\n      payload,\n      `✅ Successfully logged to API with ID for call #${callData.id}`\n    );\n\n    this.logSeparator();\n\n    return result;\n  }\n\n  /**\n   * Fetch full input/output content for a single log by ID. Requires the client's **private**\n   * API key — the public key used by {@link logRequestToAPI} will 401 here.\n   *\n   * @param logId The log's hashid.\n   * @param opts `section`/`maxChars` for large logs, or `searchQuery` for snippet search\n   *   (mutually exclusive with `section`/`maxChars` — enforced by the overloads below), plus\n   *   `includeThinking`.\n   * @throws Error if `logId` is blank/whitespace-only or a bare dot-segment (`.`/`..`) — either\n   *   would otherwise silently resolve away to the `index` route or beyond (returning a bare array\n   *   typed as a single log's content) rather than 404ing on `show`. Also throws if `searchQuery`\n   *   is blank/whitespace-only, which would silently fall through to the content shape server-side\n   *   while the overload above promises a search result. Error on network failure or a non-JSON\n   *   body. A non-2xx response throws an error whose `status` property holds the HTTP status code\n   *   (e.g. 404 for an unknown ID).\n   */\n  public async getLogContent(logId: string, opts?: GetLogContentSliceOptions): Promise<LlmRequestLogContentFull>;\n  public async getLogContent(logId: string, opts: GetLogContentSearchOptions): Promise<LlmRequestLogContentSearchResult>;\n  public async getLogContent(logId: string, opts: GetLogContentOptions): Promise<LlmRequestLogContent>;\n  public async getLogContent(logId: string, opts: GetLogContentOptions = {}): Promise<LlmRequestLogContent> {\n    const url = this.buildResourceUrl(logId, 'getLogContent: logId must be a non-empty string');\n    if (opts.section !== undefined) {\n      url.searchParams.set('section', opts.section);\n    }\n    if (opts.maxChars !== undefined) {\n      url.searchParams.set('max_chars', String(opts.maxChars));\n    }\n    if (opts.searchQuery !== undefined) {\n      // The backend branches on Rails' `.present?` (blank/whitespace-only counts as absent), not\n      // mere non-undefined-ness — sending a blank query would silently fall through to the content\n      // shape server-side while the search-options overload above promises callers a search result.\n      // typeof-checked (not just trim()) so a non-TS caller passing the wrong type gets this\n      // message instead of a raw \"searchQuery.trim is not a function\" TypeError.\n      if (typeof opts.searchQuery !== 'string' || opts.searchQuery.trim() === '') {\n        throw new Error('getLogContent: searchQuery must be a non-empty string');\n      }\n      url.searchParams.set('search_query', opts.searchQuery);\n    }\n    if (opts.includeThinking !== undefined) {\n      url.searchParams.set('include_thinking', String(opts.includeThinking));\n    }\n    return this.getJson<LlmRequestLogContent>(url.toString(), 'Log');\n  }\n\n  /**\n   * Search logs by named filters (`templateId`, `workloadId`, `model`, etc.) — not raw Ransack\n   * predicates, unlike `FeedbackService#searchFeedback` — applied on top of the endpoint's\n   * existing Ransack-backed search/sort; `sort` reaches that directly, sent as `q[s]`. Requires\n   * the client's **private** API key, same as {@link getLogContent}.\n   *\n   * @returns `{ logs, pagination }` — the matching logs for the requested page, plus pagination\n   *   totals. The backing endpoint renders `logs` as a bare array on the wire and, once\n   *   Coolhand-Labs/coolhand#1096 ships, exposes pagination via X-Total-Count/X-Page/X-Per-Page/\n   *   X-Total-Pages response headers instead of a body envelope; this method reads those headers\n   *   when present, assembling the same `Pagination` shape `searchFeedback` embeds in its body.\n   *   Until #1096 deploys, those headers are absent and `pagination` is derived from `logs`/\n   *   `params` instead (see {@link paginationFromHeaders}) rather than falsely reporting zero\n   *   results. Pass `params.includeTotal` to opt into exact totals at the cost of a `COUNT(*)` on\n   *   the backend — left unset, the estimate above is used.\n   * @throws Error on network failure or a non-JSON body. A non-2xx response throws an error\n   *   whose `status` property holds the HTTP status code.\n   */\n  public async searchLogs(params: SearchLogsParams = {}): Promise<SearchLogsResponse> {\n    const url = new URL(this.apiEndpoint);\n\n    const queryParams = {\n      template_id: params.templateId,\n      workload_id: params.workloadId,\n      system_prompt_contains: params.systemPromptContains,\n      user_prompt_contains: params.userPromptContains,\n      model: params.model,\n      source_api: params.sourceApi,\n      source_api_result: params.sourceApiResult,\n      unmatched_only: params.unmatchedOnly,\n      days_back: params.daysBack,\n      include_prompts: params.includePrompts,\n      'q[s]': params.sort,\n      page: params.page,\n      per: params.per,\n      include_total: params.includeTotal\n    };\n    for (const [key, value] of Object.entries(queryParams)) {\n      if (value !== undefined) {\n        url.searchParams.set(key, String(value));\n      }\n    }\n    const { body, headers } = await this.getJsonWithHeaders<LlmRequestLogSummary[]>(url.toString(), 'Log');\n    return { logs: body, pagination: this.paginationFromHeaders(headers, body, params) };\n  }\n\n  // X-Total-Count/X-Page/X-Per-Page/X-Total-Pages are set by the backend's pagination_headers\n  // concern (Coolhand-Labs/coolhand#1096, not yet merged as of this writing) — until that ships,\n  // X-Total-Count is absent here.\n  private paginationFromHeaders(headers: Headers, logs: LlmRequestLogSummary[], params: SearchLogsParams): Pagination {\n    // Api::V2::LlmRequestLogsController::DEFAULT_PER_PAGE/MAX_PER_PAGE — mirrored here only for\n    // the pre-#1096 fallback below; once X-Total-Count is present, the server's real values are\n    // used directly instead.\n    const DEFAULT_PER_PAGE = 25;\n    const MAX_PER_PAGE = 100;\n\n    // Math.trunc (not Math.floor) on `per` to match how the server coerces it — `per_page` does\n    // `(params[:per] || params[:per_page]).to_i`, and Ruby's String#to_i truncates toward zero\n    // (e.g. \"-5.5\" -> -5, not -6). `page` gets no equivalent server-side coercion — will_paginate\n    // raises on a non-positive/non-integer page rather than clamping it — so `requestedPage` below\n    // is truncated/clamped purely to keep this SDK's own pre-#1096 fallback `Pagination` object\n    // sane, not to mirror server behavior. `params.page`/`params.per` are typed `number`, so a\n    // caller-supplied `NaN` is type-legal; `Math.max(1, NaN)` is `NaN`, not `1`, so that's guarded\n    // explicitly rather than relying on the clamp.\n    const truncatedPage = Math.trunc(params.page ?? 1);\n    const requestedPage = Number.isFinite(truncatedPage) ? Math.max(1, truncatedPage) : 1;\n    const truncatedPer = Math.trunc(params.per ?? 0);\n    const requestedPer = truncatedPer > 0 ? Math.min(truncatedPer, MAX_PER_PAGE) : DEFAULT_PER_PAGE;\n    const hasTotalCount = headers.get('x-total-count') !== null;\n\n    const currentPage = Math.max(1, parseHeaderInt(headers.get('x-page'), requestedPage));\n    const perPage = parseHeaderInt(headers.get('x-per-page'), requestedPer);\n\n    let totalCount: number;\n    let totalPages: number;\n    let hasNextPage: boolean;\n    let hasPrevPage: boolean;\n\n    if (hasTotalCount) {\n      totalCount = parseHeaderInt(headers.get('x-total-count'), logs.length);\n      // Fall back to a value derived from totalCount (not e.g. currentPage) if X-Total-Pages is\n      // itself missing/malformed — a fallback unrelated to the real count could under-report the\n      // page count and truncate a caller's pagination loop, exactly what totalCount's own\n      // fallback (logs.length above) exists to avoid on the sibling header.\n      totalPages = parseHeaderInt(\n        headers.get('x-total-pages'),\n        perPage > 0 ? Math.ceil(totalCount / perPage) : (totalCount > 0 ? 1 : 0)\n      );\n      hasNextPage = currentPage < totalPages;\n      // Both searchLogs and searchFeedback's backends paginate via will_paginate (not Kaminari —\n      // ActiveRecord::Relation#page is will_paginate's; Kaminari is only used elsewhere, on plain\n      // arrays), whose `previous_page` has no out-of-range check: `current_page > 1 ? ... : nil`.\n      // Match that exactly (no `&& currentPage <= totalPages` guard) for genuine parity with\n      // searchFeedback's `previous_page.present?`, rather than inventing stricter semantics no\n      // backend here actually implements.\n      hasPrevPage = currentPage > 1;\n    } else if (logs.length > 0) {\n      // Rather than let a missing/malformed header silently report total_count: 0 alongside a\n      // non-empty `logs`, total_count/total_pages fall back to a lower-bound estimate: every\n      // prior page assumed full, plus this page's actual result count. This bound is sound\n      // specifically *because* `logs` is non-empty — offset-based pagination (`OFFSET (page-1)*per\n      // LIMIT per`) can only return rows if that many preceding rows exist, so a real result at\n      // `currentPage` proves at least `(currentPage - 1) * perPage` prior rows exist regardless of\n      // how large `currentPage` is. It's still only ever a lower bound though — NOT reliable\n      // enough to derive has_next_page/has_prev_page from (a full page doesn't prove there's\n      // nothing beyond it), so has_next_page is derived independently: a full page implies there\n      // may be more.\n      totalCount = perPage > 0 ? (currentPage - 1) * perPage + logs.length : logs.length;\n      totalPages = perPage > 0 ? Math.ceil(totalCount / perPage) : currentPage;\n      hasNextPage = perPage > 0 && logs.length >= perPage;\n      hasPrevPage = currentPage > 1;\n    } else {\n      // An EMPTY page proves the opposite of a non-empty one: it means `currentPage` is at or past\n      // the end (or the search matched nothing at all), not that `(currentPage - 1) * perPage`\n      // prior rows exist — extrapolating from `currentPage` here would fabricate a total governed\n      // entirely by whatever `page` the caller happened to pass (e.g. `page: 1000000` -> a\n      // fictitious ~25M-row total). With no evidence of how many real rows exist, report what we\n      // can actually confirm: none, on this page.\n      totalCount = 0;\n      totalPages = 0;\n      hasNextPage = false;\n      // Still page-relative, not result-relative: a caller can always step back toward page 1\n      // regardless of whether this specific page happened to come back empty.\n      hasPrevPage = currentPage > 1;\n    }\n\n    return {\n      current_page: currentPage,\n      per_page: perPage,\n      total_count: totalCount,\n      total_pages: totalPages,\n      has_next_page: hasNextPage,\n      has_prev_page: hasPrevPage\n    };\n  }\n\n  private logRequestInfo(callData: CoolhandCallData, matchedPattern?: CoolhandMatchedPattern): void {\n    if (!this.silent) {\n      const apiName = matchedPattern?.pattern.name || 'API';\n      console.log(`\\n🎉 LOGGING ${apiName} API Call #${callData.id}`);\n      console.log(`🕐 Time: ${callData.timestamp}`);\n      console.log(`🎯 ${callData.method} ${callData.url}`);\n      console.log(`📊 Status: ${callData.status_code}`);\n      console.log(`🔧 Protocol: ${callData.protocol}`);\n      if (matchedPattern) {\n        console.log(`🔍 Matched by: ${matchedPattern.matchType} (${matchedPattern.matchValue})`);\n      }\n\n      if (callData.request_body?.model) {\n        console.log(`🤖 Model: ${callData.request_body.model}`);\n      }\n\n      if (callData.request_body?.messages) {\n        console.log(`💬 Messages: ${callData.request_body.messages.length}`);\n      }\n\n      if (callData.request_body?.temperature !== undefined) {\n        console.log(`🌡️  Temperature: ${callData.request_body.temperature}`);\n      }\n\n      if (this.dryRun) {\n        console.log(`🚫 DRY RUN: API call will be skipped`);\n      } else {\n        console.log(`📤 Sending to: ${this.apiEndpoint}`);\n      }\n    }\n  }\n\n}"],"mappings":"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","names":[]}