Ctrl K
ComunidadGuías…

src/devin-connect-metrics.js

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
/**
 * DEVIN_CONNECT operational counters — in-memory, lifetime-of-process.
 *
 * WHY: the DEVIN_CONNECT recovery machinery (re-login, failover, liveness,
 * cooldowns) only emitted log lines. During an incident there was no way to
 * answer "how many tokens died?", "how often are we failing over?", or "is
 * auto-relogin even succeeding?" without grepping logs. These counters make
 * that state queryable (dashboard /connect-metrics) so it can be watched and
 * alarmed on.
 *
 * Deliberately NOT persisted: they're health/rate signals, not billing — a
 * restart resetting them is correct (you want "since this process started").
 * Counting is cheap and side-effect-free; callers bump on the event path.
 */

const _counters = {
  // re-login (same-account token recovery via stored credentials)
  relogin_ok: 0,
  relogin_fail: 0,
  // cross-account failover hops taken after a dead/unrecoverable token
  failover_hops: 0,
  // requests that exhausted all failover hops / pooled accounts
  failover_exhausted: 0,
  // cross-account failover hops taken because THIS account hit a quota/rate
  // dry-well (QUOTA_EXHAUSTED / RATE_LIMITED) while the pool still had a healthy
  // account — the request moves on instead of surfacing 402/429 to the client (R1)
  quota_failover_hops: 0,
  // cross-account failover hops taken because the upstream refused THIS selector
  // for THIS account ("This model is no longer available for your account").
  // Sibling of quota_failover_hops, deliberately separate: a spike here means the
  // upstream retired a model for part of the pool, not that anyone is out of
  // credit. No errorCount / re-login / reauth is involved (see
  // finalizeConnectAccount's SELECTOR_NOT_ELIGIBLE arm).
  selector_failover_hops: 0,
  // upstream refused a specific selector for one account. Scoped to the
  // (account, selector) pair: the token is alive and every other selector keeps
  // working, so this counter moving is NOT an account-health signal. It counts the
  // REFUSAL, not the routing latch: with no eligible peer the latch is deliberately
  // skipped (the account must keep surfacing the honest 403) and the refusal still
  // counts here.
  selector_ineligible: 0,
  // session tokens observed dead (UNAUTHORIZED, not revived on same account)
  dead_tokens: 0,
  // requests rejected because the whole pool was rate-limited/unavailable
  pool_exhausted: 0,
  // streaming requests replayed after a pre-emit transient blip
  transient_replays: 0,
  // accounts cooled down for running out of credit/quota
  quota_exhausted: 0,
  // requests where the MODEL was temporarily at capacity ("high demand"); a
  // transient soft cooldown, not an account fault (P0 #56/#57)
  capacity_throttled: 0,
  // upstream returned a transient backend fault ("an internal error occurred
  // (trace ID/error ID: ...)"), often in a 401/403 shell, on a provably-alive
  // token; classified UPSTREAM_INTERNAL → streak-quarantine, NOT a re-login or
  // entitlement wall (#56/#57 shape, internal-error class)
  upstream_internal: 0,
  // the upstream request VALIDATOR refused the request (gRPC invalid_argument /
  // failed_precondition) — an unsupported image / model capability (measured
  // 2026-10-25, evidence/AccountMatrix.md §7.1) or a malformed message/tool
  // shape. Surfaced to the client as 400 invalid_request_error, never retried,
  // and NEVER an account penalty: the token is alive and the account is healthy.
  // A spike means callers are sending a shape the selected model cannot take;
  // the client-facing message names supports_images in /v1/models.
  request_rejected: 0,
  // upstream returned an unclassified error, or the gRPC `internal` class that
  // classifyUpstreamError documents as a PERMANENT CLIENT mistake (short
  // fingerprint / gzipped body). Neither is an account fault, so it is recorded
  // as a health event only — never an errorCount eviction (same treatment as
  // CONTENT_BLOCKED). A spike here means callers are sending bad requests.
  upstream_error: 0,
  // upstream socket ended mid-answer with no end-of-stream frame → the reply was
  // TRUNCATED. A transport fault (ECONNRESET class), not an account fault, so it
  // is a health event only — never an errorCount eviction. A spike here means the
  // network path to the upstream is dropping connections, and callers are seeing
  // `response.incomplete` / retried turns rather than silent half answers.
  stream_truncated: 0,
  // upstream stall / transport / configuration faults (idle TIMEOUT, absolute
  // DEADLINE_EXCEEDED, NO_TOKEN). Health events only — never an errorCount
  // eviction, since none of them says the ACCOUNT is unhealthy. A spike here means
  // the upstream is stalling or the deployment is misconfigured, not that the pool
  // is dying.
  transport_fault: 0,
  // an error code finalizeConnectAccount did not classify. DENY-BY-DEFAULT: the
  // blame chain is an allow-list of named verdicts, so an unknown code takes the
  // health-window path (reportInternalError) and never charges the errorCount
  // budget. This counter is the visibility that the old silent `else reportError`
  // lacked: a spike means devin-connect.js (or Node's socket layer — EPROTO,
  // HPE_*, ERR_SSL_* are produced by https.request itself) emitted a code with no
  // explicit blame decision. Add an arm rather than widening an enumeration.
  unclassified_fault: 0,
  // the LOCAL frame queue hit its cap because the consumer was slower than the
  // upstream stream (QUEUE_OVERFLOW in devin-connect.js). Not an account fault —
  // the upstream and its token are healthy — so it carries no errorCount penalty.
  // A spike means DEVIN_CONNECT_MAX_QUEUE_FRAMES/BYTES or a downstream writer is
  // the bottleneck, not the pool.
  queue_overflow: 0,
  // Inline images dropped with an image-only ASSISTANT turn (#272). The drop is
  // deliberate — the upstream ignores #10 on role=assistant (measured 2026-10-21)
  // — but it used to be silent. A non-zero value means a client is replaying
  // images on assistant turns and those images will not reach the model.
  assistant_images_dropped: 0,
  // Image blocks dropped because the live catalog EXPLICITLY declares
  // `supports_images=false` for the resolved selector (C3-01). /v1/models has
  // advertised that bit since 3.9.23; this counter is the request-path proof
  // that it now governs forwarding. The text turn is preserved, so a non-zero
  // value means the client sent images to a model the upstream calls text-only
  // and the caller got an answer without them. Deliberately NOT an errorCount
  // signal: the request succeeds, and a catalog bit must never degrade account
  // health. Absence of the bit is unknown and never counted (see
  // getConnectSelectorImageCapability).
  images_dropped_capability: 0,
  // Turns where the model NARRATED a tool call in visible text, then finished
  // with `stop` and zero tool calls (#236 "form A"). Log-only detector: nothing
  // is retried or rewritten, because the shape cannot be told apart from a
  // legitimate prose answer without a real capture. A spike here is that capture.
  prose_tool_intent: 0,
  // Remote http(s) image_url blocks resolved to data URLs before the connect
  // wire was built (inlineRemoteImages). `inlined` moving means clients use
  // hosted image URLs; a matching rise in `fetch_failed` means those fetches are
  // being refused/blocked/oversized and the image was dropped (text still sent).
  remote_images_inlined: 0,
  remote_image_fetch_failed: 0,
  // A remote image_url reached the SYNCHRONOUS wire builder unresolved, i.e. a
  // direct buildGetChatMessageRequest() caller skipped streamChat's resolver.
  // The image is not sent; the log line names the host. Production should stay 0.
  remote_image_unresolved: 0,
  // Requests that asked for `parallel_tool_calls:false` (OpenAI) /
  // `tool_choice.disable_parallel_tool_use` (Anthropic) while the #11
  // `disable_parallel_tool_calls` passthrough was OFF, i.e. the hint was accepted
  // and served but not honoured — the upstream may return several calls in one
  // turn. Counted per request, because the log line for it is once per process.
  //
  // Non-zero is EXPECTED, not a fault: Codex CLI 0.164 sends the hint on every
  // request (evidence/ClientSandboxes.md §3.3 C1). It is the signal that would
  // justify calibrating #11 (DEVIN_CONNECT_TOOL_CHOICE=1 or a tag override).
  // Deliberately NOT an errorCount signal: nothing failed, and a caller preference
  // must never degrade account health.
  parallel_tool_calls_hint_ignored: 0,
  // liveness probe pre-emptively recovered a dying token
  liveness_recovered: 0,
  // SessionReauth: accounts benched because a free GetUserStatus probe confirmed
  // the session token was retired, and the ways back out. A `reauth_required`
  // that never finds a matching `reauth_ok` is a token nobody re-authenticated.
  reauth_required: 0,
  reauth_cleared: 0,
  reauth_ok: 0,
  reauth_fail: 0,
  // router-model (adaptive/arena-*) AssignModel resolution failed → fell back
  assign_model_failed: 0,
  // credential store was read back through a resilience tier (JSON repair or
  // per-record regex salvage) instead of a clean parse — a single corrupt byte
  // would otherwise wipe the whole fleet's stored relogin credentials (C6)
  cred_store_repaired: 0,
  // an account-gone cleanup (conversation-pool invalidateFor / detached
  // evictForAccount / user-JWT epoch bump) rejected or threw (G14). The
  // cleanup is fire-and-forget by design, but a failure leaves stale
  // pool/detached/JWT state behind — this counter plus the
  // `account cleanup "<op>" failed` warn makes that observable instead of
  // silent. A non-zero value means an operator should check whether a
  // removed/banned account is still resumable.
  account_cleanup_failed: 0,
};
let _startedAt = Date.now();

/** Increment a named counter by n (default 1). Unknown names are ignored. */
export function bumpConnect(name, n = 1) {
  if (Object.prototype.hasOwnProperty.call(_counters, name)) _counters[name] += n;
}

/**
 * Snapshot of all counters plus credential-store decrypt health (folded in so
 * a single endpoint answers "is recovery healthy?"). uptimeMs lets a scraper
 * compute rates without a separate clock.
 */
export function getConnectMetrics() {
  let cred = { decryptFailures: 0, lastDecryptError: null };
  try {
    // Lazy require to avoid a load-time cycle (credentials.js → config → ...).
    // Synchronous access via the module's getter when it's already loaded.
    const m = _credHealthRef;
    if (m) cred = m();
  } catch { /* leave defaults */ }
  return {
    uptimeMs: Date.now() - _startedAt,
    ..._counters,
    credDecryptFailures: cred.decryptFailures,
    credLastDecryptError: cred.lastDecryptError,
  };
}

// The credentials module registers its health getter here at import time so we
// avoid a static import cycle while still surfacing cred health in one place.
let _credHealthRef = null;
export function __registerCredHealth(fn) { _credHealthRef = fn || null; }

/** Reset counters (tests / dashboard DELETE). */
export function resetConnectMetrics() {
  for (const k of Object.keys(_counters)) _counters[k] = 0;
  _startedAt = Date.now();
}