Skip to content

Commit ba09f76

Browse files
authored
feat(observability): installationId on loggers + config-free MCP retry (#189)
Closes #177 Closes #184
1 parent c615bc4 commit ba09f76

15 files changed

Lines changed: 172 additions & 29 deletions

File tree

docs/operate/observability.md

Lines changed: 20 additions & 19 deletions
Large diffs are not rendered by default.

src/daemon/job-executor.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -274,8 +274,15 @@ export async function executeJob(
274274
return;
275275
}
276276

277-
const { installationToken, maxTurns, allowedTools, envVars, memory, reviewLearnings } =
278-
payload.payload;
277+
const {
278+
installationToken,
279+
installationId,
280+
maxTurns,
281+
allowedTools,
282+
envVars,
283+
memory,
284+
reviewLearnings,
285+
} = payload.payload;
279286

280287
// Abort controller for cancel/execute race prevention (C1)
281288
const abortController = new AbortController();
@@ -316,6 +323,9 @@ export async function executeJob(
316323
owner: context.owner,
317324
repo: context.repo,
318325
entityNumber: context.entityNumber,
326+
// Per-installation rate-limit triage (#177). Orchestrator-sourced; absent
327+
// in PAT mode, so emit conditionally.
328+
...(installationId !== undefined ? { installationId } : {}),
319329
});
320330

321331
// Build the full BotContext. `envVars` is threaded through the context so

src/mcp/mcp-logger.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,13 +14,15 @@
1414
*/
1515
import pino from "pino";
1616

17-
import { errSerializer, REDACT_PATHS } from "../utils/log-redaction";
17+
import { errSerializer, REDACT_PATHS, resolveLogLevel } from "../utils/log-redaction";
1818

1919
export function createMcpLogger(serverName: string): pino.Logger {
2020
const deliveryId = process.env["DELIVERY_ID"];
2121
return pino(
2222
{
23-
level: process.env["LOG_LEVEL"] ?? "info",
23+
// resolveLogLevel falls back to `info` on an invalid LOG_LEVEL so pino
24+
// can't throw at construction in this config-free subprocess (#184).
25+
level: resolveLogLevel(process.env["LOG_LEVEL"]),
2426
base: {
2527
server: serverName,
2628
...(deliveryId !== undefined && deliveryId !== "" ? { deliveryId } : {}),

src/mcp/servers/resolve-review-thread.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -170,8 +170,9 @@ server.tool(
170170
// to 3 attempts) is actually honoured. The helper short-circuits
171171
// on non-retriable 4xx (e.g. 404 thread_not_found) so callers
172172
// still see prompt failures.
173-
const preflight = await retryWithBackoff(() =>
174-
octokit.graphql<PreflightResponse>(GET_THREAD_QUERY, { threadId: thread_id }),
173+
const preflight = await retryWithBackoff(
174+
() => octokit.graphql<PreflightResponse>(GET_THREAD_QUERY, { threadId: thread_id }),
175+
{ log },
175176
);
176177
const preflightPr = preflight.node?.pullRequest.number;
177178
const preflightRepo = preflight.node?.pullRequest.repository;
@@ -205,8 +206,9 @@ server.tool(
205206
};
206207
}
207208

208-
const result = await retryWithBackoff(() =>
209-
octokit.graphql<ResolveResponse>(RESOLVE_MUTATION, { threadId: thread_id }),
209+
const result = await retryWithBackoff(
210+
() => octokit.graphql<ResolveResponse>(RESOLVE_MUTATION, { threadId: thread_id }),
211+
{ log },
210212
);
211213
const thread = result.resolveReviewThread.thread;
212214

src/orchestrator/connection-handler.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -907,6 +907,10 @@ async function handleAccept(
907907
// (per-repo config fetch, 1.5.F) can reuse it without re-minting.
908908
let token: string;
909909
let acceptOctokit: Octokit;
910+
// App mode only: the installation id used to mint the token. Forwarded into
911+
// job:payload so the daemon's child logger can emit it (#177). Stays
912+
// undefined in PAT mode (no per-installation bucket to triage).
913+
let installationId: number | undefined;
910914
if (config.githubPersonalAccessToken !== undefined) {
911915
token = config.githubPersonalAccessToken;
912916
acceptOctokit = new Octokit({ auth: token });
@@ -916,6 +920,7 @@ async function handleAccept(
916920
owner,
917921
repo,
918922
});
923+
installationId = installation.id;
919924
acceptOctokit = await app.getInstallationOctokit(installation.id);
920925
token = await resolveGithubToken(acceptOctokit);
921926
}
@@ -961,6 +966,7 @@ async function handleAccept(
961966
daemonId,
962967
deliveryId: offer.deliveryId,
963968
installationToken: token,
969+
...(installationId !== undefined ? { installationId } : {}),
964970
contextJson,
965971
...(maxTurns !== undefined ? { maxTurns } : {}),
966972
allowedTools: resolveAllowedTools(

src/orchestrator/job-dispatcher.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -467,6 +467,10 @@ export interface JobAcceptParams {
467467
daemonId: string;
468468
deliveryId: string;
469469
installationToken: string;
470+
/** GitHub App installation id (App mode only; absent in PAT mode). Forwarded
471+
* into `job:payload` so the daemon's child logger can emit it for
472+
* per-installation rate-limit triage (#177). */
473+
installationId?: number;
470474
contextJson: Record<string, unknown>;
471475
/** Optional turn cap. Omitted = no cap (the SDK runs the agent to completion). */
472476
maxTurns?: number;
@@ -501,6 +505,7 @@ export function handleJobAccept({
501505
daemonId,
502506
deliveryId,
503507
installationToken,
508+
installationId,
504509
contextJson,
505510
maxTurns,
506511
allowedTools,
@@ -527,6 +532,7 @@ export function handleJobAccept({
527532
payload: {
528533
context: contextJson,
529534
installationToken,
535+
...(installationId !== undefined ? { installationId } : {}),
530536
...(maxTurns !== undefined ? { maxTurns } : {}),
531537
allowedTools,
532538
...(Object.keys(envVars).length > 0 ? { envVars } : {}),

src/shared/ws-messages.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,11 @@ const jobPayloadSchema = z.object({
183183
payload: z.object({
184184
context: z.record(z.string(), z.unknown()),
185185
installationToken: z.string(),
186+
/** GitHub App installation id (App mode only; absent in PAT mode). The
187+
* daemon emits it on its child logger for per-installation rate-limit
188+
* triage (#177). Optional for rolling-deploy safety: an orchestrator that
189+
* omits it must not fail a newer daemon's parse. */
190+
installationId: z.number().int().positive().optional(),
186191
maxTurns: z.number().int().positive().optional(),
187192
allowedTools: z.array(z.string()),
188193
envVars: z.record(z.string(), z.string()).optional(),

src/utils/log-redaction.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,22 @@ import pino, { type SerializedError } from "pino";
1313

1414
import { redactGitHubTokens } from "./sanitize";
1515

16+
const VALID_PINO_LEVELS = new Set(["trace", "debug", "info", "warn", "error", "fatal", "silent"]);
17+
18+
/**
19+
* Resolve a pino `level` from a raw env value, config-free.
20+
*
21+
* pino 10 throws at construction on a non-core level string (e.g. a typo'd
22+
* `LOG_LEVEL`), so a logger built straight from `process.env` would crash at
23+
* module import. The main process is protected by config's zod enum, but the
24+
* stdio MCP subprocesses and `retryWithBackoff`'s default logger deliberately
25+
* do NOT load config, so they validate here and fall back to `info` on anything
26+
* unrecognised. See issue #184.
27+
*/
28+
export function resolveLogLevel(raw: string | undefined): string {
29+
return raw !== undefined && VALID_PINO_LEVELS.has(raw) ? raw : "info";
30+
}
31+
1632
/**
1733
* Path-based redaction list for pino loggers.
1834
*

src/utils/retry.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,29 @@
1-
import { type Logger, logger as rootLogger } from "../logger";
1+
import pino from "pino";
2+
3+
import type { Logger } from "../logger";
4+
import { errSerializer, REDACT_PATHS, resolveLogLevel } from "./log-redaction";
5+
6+
// Config-free default logger (issue #184). Importing retry.ts must not pull in
7+
// src/logger.ts -> src/config, so the stdio MCP servers that use retry (e.g.
8+
// resolve-review-thread) stay config-free. `import type { Logger }` above is
9+
// erased at emit, so it adds no runtime coupling. Same REDACT_PATHS +
10+
// errSerializer as the root logger keeps redaction parity. The level reads
11+
// LOG_LEVEL directly (config is intentionally not imported) via resolveLogLevel,
12+
// which falls back to `info` on an invalid value so pino can't throw at import.
13+
// Level visibility matches the root logger because both derive from LOG_LEVEL.
14+
//
15+
// Writes to stderr (like createMcpLogger), NOT pino's default stdout: a stdio
16+
// MCP server speaks JSON-RPC over stdout, so a default-path retry warning on
17+
// stdout would corrupt the protocol. stderr is safe in every context (k8s
18+
// ships both streams; warn/error on stderr is conventional).
19+
const defaultLog: Logger = pino(
20+
{
21+
level: resolveLogLevel(process.env["LOG_LEVEL"]),
22+
redact: { paths: [...REDACT_PATHS] },
23+
serializers: { err: errSerializer },
24+
},
25+
process.stderr,
26+
);
227

328
/**
429
* Retry configuration options.
@@ -60,7 +85,7 @@ export async function retryWithBackoff<T>(
6085
initialDelayMs = 5000,
6186
maxDelayMs = 20000,
6287
backoffFactor = 2,
63-
log = rootLogger,
88+
log = defaultLog,
6489
} = options;
6590

6691
// Fail fast on invalid input. Each check names the offending option and

src/webhook/events/issue-comment.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,11 @@ export function handleIssueComment(
5353
// so the canonical `entityNumber` covers both surfaces under one field.
5454
entityNumber: payload.issue.number,
5555
senderLogin,
56+
// Per-installation rate-limit triage (#177). Conditional because this
57+
// logger is built before the `payload.installation === undefined` guard
58+
// below; the guard can't move up (the owner-allowlist drop line logs
59+
// through `log` first).
60+
...(payload.installation !== undefined ? { installationId: payload.installation.id } : {}),
5661
});
5762

5863
const auth = isOwnerAllowed(ownerLogin, log);

0 commit comments

Comments
 (0)