Application Logging for AI Agents
How to write ordinary application logs — for any service — so a coding agent like Cursor can troubleshoot with you instead of guessing.
What you will read
This is not an article about logging the AI agents inside your product.
It is about application logs: the structured records your API, worker, CLI, or backend already emits (or should emit). The reader that matters now is often a coding agent — Cursor, Copilot, and the like — helping you find why a request failed, returned the wrong payload, or behaved oddly in production.
Two rules drive everything else:
- Application logs should carry full context so an agent can review one request and quickly understand the exact response — what came in, what the system did, and what went out.
- Give the agent access to the logs so it can query the neighborhood around a failure — earlier requests, related ids, sibling steps — instead of waiting for you to paste a fragment.
You will also see how I log in practice (structured context at the edge, warn vs fatal) and how I throw with AppError — .withMeta(...) for safe business facts, .withDebug(...) for ops-only detail that stays in logs.
The through-line: log for comprehension, open for investigation.
Disclaimer: Observability products already ship AI that can investigate as soon as an error is raised — Sentry Seer, Datadog Bits AI, Dynatrace Davis, New Relic AI, Elastic’s AI Assistant, and similar. This note is not about replacing those. It is about how you structure application logs so a coding agent in your editor (or those same platform AIs) has enough evidence to reason from.
For my personal projects — where I do not have a real logging / observability stack like those — troubleshooting is very simple: I copy the log event into Cursor. With enough context on the line, Cursor usually knows what to do next. Sometimes I add a short note (symptom, what I expected, which environment). Often the event alone is enough.
The shift: logs used to be for you
For years, application logs were breadcrumbs for a human who already knew the codebase. You grepped. You remembered that orderId lived three services away. You filled gaps from tribal knowledge.
Coding agents do not have that tribal knowledge. They are fast at reading evidence and slow at inventing what you forgot to record. If the log is thin, they hallucinate a story. If the log is complete and reachable, they localize the bug.
| Old mindset | Agent-ready application logging |
|---|---|
| Log enough for a human who wrote the feature | Log enough for an agent that did not |
| Status + short message | Full request context + exact response / outcome |
| You grep; you decide what matters | You give an id; the agent queries |
| Paste a snippet when stuck | Agent can fetch the request and its neighbors |
| Logs are an ops afterthought | Logs are how the coding agent sees production |
If Cursor has to ask “what was in the response body?” or “what was the request payload?”, the log failed — not the model.
Mindset 1 — Full context in the application log
An agent cannot infer what you forgot to write down. Design each serious log event as a self-contained brief: after one read, the agent should understand this request without a second trip to your head.
What “full context” means
Not a novel. A complete frame for the unit of work (HTTP request, job, message handler):
- Who / where: tenant or workspace, principal, service, environment
- Correlation:
requestId(andjobId/messageId/traceIdif you have them) - What came in: method, path, key params or a redacted body summary
- What the system did: important branches, downstream calls, cache hit/miss, validation failures
- What went out: status, exact response (or a faithful summary of the payload the client got), error code / message
- Timing: duration; optionally downstream durations
The test I use: If I handed only this log line (or this request’s log group) to Cursor in a fresh session, could it explain the response in one pass? If not, the context is incomplete.
Thin logs feel fine until an agent reads them
INFO POST /orders 200 142ms requestId=req_8f3a is enough for uptime dashboards. It is useless for “why did we charge the customer twice?” or “why is total null in the JSON?” The agent needs the order id, the idempotency key, the branch that ran, and the response body (or its structured fields). Without that, it invents theories. With that, it can usually localize the real bug.
Log the response you actually returned
“Full context” includes the outbound side. Agents debug wrong answers and wrong payloads as often as they debug 500s. If you only log “ok”, they cannot see that you returned { "status": "paid" } while the DB still said pending. Prefer structured fields for the response shape you care about; redact secrets; keep enough that the agent can match log ↔ client symptom.
Mindset 2 — Let the agent access the neighborhood
Full context on one event is necessary. It is not sufficient.
Bugs live in the neighborhood: the prior request that created the row, the retry that double-wrote, the worker that failed after the API returned 200, the sibling call with the same idempotency key. A pasted snippet freezes time. An agent with access can walk sideways.
What “access” looks like
You do not need a research platform. You need a path the coding agent can use:
- Queryable store — files, Docker logs, CloudWatch / Datadog / Loki, Postgres audit tables, or whatever you already have
- Stable ids on every related line so “neighbors of
req_8f3a” is a query - A fetch path — CLI, script, API, MCP, or “read this log file” — so you are not the paste buffer
- Bounded permissions — read-only where possible, redacted, environment-scoped
The mindset shift: stop being the agent’s log proxy. Point it at the id (or time window + service). Let it pull the request. Let it ask for ±N minutes, same orderId, same user, same job.
Why neighborhood queries beat bigger pastes
Pastes are lossy and static. Neighborhood queries find:
- the create that succeeded before the update that failed
- three retries that looked like “slowness”
- a race between two requests with the same key
- a worker error five seconds after a 200
Tell the agent: Start from req_8f3a. Load that request’s logs. Query the same orderId for ±30 minutes across api and worker. Compare. That is investigation. Handing it one sad line is theater.
How the two mindsets fit together
- Step 1 Write full context
- Step 2 Open log access
- Step 3 Query the neighborhood
- Step 4 Fix with evidence
Vendors and OpenTelemetry help when you outgrow files and grep — they are optional. The mindset is not optional: you still need comprehensible application logs and reachable stores.
How I actually log
Patterns I use across ordinary backends (APIs, workers, jobs). Names below are fictionalized as Harbor Desk; the shape is what I ship.
Structured logger + request context
- Child logger per operation (
Checkout.Complete, workeroperationId). - At the edge of every HTTP/worker call:
setContext({ requestId, spec, http|worker, principal })so later lines inherit ids without re-threading them. - Levels that mean something:
infofor success path,warnfor expected client/domain failures,fatalfor unexpected server failures (and alert on fatal). - JSON lines in production; pretty ANSI locally.
- Automatic redaction of sensitive keys (
password,token,authorization,cookie,api_key, …) and light email masking before write. - When an
AppErroris logged, the logger serializesid,code,severity,meta,debug, andinnerErrorinto theerrorfield — so Cursor sees the same bag you threw.
Success path (edge) — set context once, then log the outcome fields the agent will need (full event shape is in the samples below):
this.logger.setContext({
requestId,
spec: { operationId: this.spec.operationId },
http: { method: request.method, path: request.path },
})
// ...
this.logger.info(`${request.method} ${request.path} - ${status} - ${duration}ms`, {
http: { status, duration },
input: { cartId, idempotencyKey },
orderId,
response: { orderId, paymentStatus, totalCents },
})
Failure path (edge): expected failures are warn; true server failures are fatal — always with the error object attached:
const appError = AppError.of(error)
const status = AppError.CODES[appError.severity].status
if (appError.severity === 'SERVER_ERROR') {
this.logger.fatal(appError.message, { error: appError, http: { status } })
} else {
this.logger.warn(appError.message, { error: appError, http: { status } })
}
That pairing matters for agents: the log line is not “something failed” — it is the full error record under a shared requestId.
How I throw — withMeta vs withDebug
I do not throw new Error('not found'). I throw a typed application error with a stable code, a human message, and then attach bags:
| Bag | Purpose | Safe for API clients? | Useful in logs for Cursor? |
|---|---|---|---|
.withMeta({ ... }) |
Business facts that explain the failure (ids, status, allowed vs actual) | Yes — included in the serialized error body | Yes |
.withDebug({ ... }) |
Ops-only detail (downstream hop status, attempt counts, noisy payloads — never secrets) | No — stripped from the client response | Yes — logger keeps it on the error object |
.withInnerError(err) |
Wrap an unknown/lower error | No (server errors hide internals) | Yes |
Factory + chain:
// Expected domain failure — meta is part of the contract with clients and logs
throw AppError.resourceNotFound(
'order.not_found',
'Order not found',
).withMeta({ orderId, workspaceId })
throw AppError.badRequest(
'checkout.invalid_status',
'Only pending carts can be checked out',
).withMeta({ cartId, status: cart.status })
throw AppError.forbidden(
'auth.permission_denied',
'You are not allowed to perform this action',
).withMeta({ permissions: required, actorRole: role })
// Downstream failure — debug has noisy ops detail; meta stays safe for clients/logs
throw AppError.serverError('Inventory service unavailable')
.withInnerError(error)
.withMeta({ orderId, sku: 'SKU-1042' })
.withDebug({
downstream: { method: 'POST', path: '/v1/reserve', status: 503, latencyMs: 2104 },
attempt: 2,
})
Rule of thumb:
meta= what you would put on a ticket for another engineer and safely show the client.debug= what helps an agent reconstruct the failure but must not leak to the browser.- Never put secrets in either bag without redaction — the logger scrubs known keys, but do not rely on that as your only control.
When something unknown blows up, normalize once:
const appError = AppError.of(unknown) // wraps Error / Zod / raw into AppError
Then log that object. Cursor gets code, severity, meta, debug, and stack in one place.
Samples (fictionalized)
Incomplete success log
INFO POST /checkout 200 142ms requestId=req_8f3a
Full-context success log
{
"level": "info",
"name": "Checkout.Complete",
"msg": "POST /checkout - 200 - 142ms",
"requestId": "req_8f3a…",
"http": { "method": "POST", "path": "/checkout", "status": 200, "duration": 142 },
"principal": { "workspaceId": "ws_…", "userId": "usr_…" },
"input": { "cartId": "cart_19", "idempotencyKey": "idem_77" },
"orderId": "ord_441",
"response": {
"orderId": "ord_441",
"paymentStatus": "paid",
"totalCents": 12900
}
}
Error log — not-found with withMeta
throw AppError.resourceNotFound('order.not_found', 'Order not found')
.withMeta({ orderId: 'ord_441', workspaceId: 'ws_9' })
Logged
{
"level": "warn",
"name": "Orders.Get",
"msg": "Order not found",
"requestId": "req_8f3a…",
"http": { "method": "GET", "path": "/orders/ord_441", "status": 404 },
"error": {
"id": "err_01J…",
"code": "order.not_found",
"severity": "NOT_FOUND",
"message": "Order not found",
"meta": { "orderId": "ord_441", "workspaceId": "ws_9" }
}
}
Error log — domain bad request
throw AppError.badRequest(
'checkout.invalid_status',
'Only pending carts can be checked out',
).withMeta({ cartId: 'cart_19', status: 'paid' })
Logged
{
"level": "warn",
"msg": "Only pending carts can be checked out",
"requestId": "req_91c2…",
"http": { "status": 400 },
"error": {
"id": "err_01K…",
"code": "checkout.invalid_status",
"severity": "BAD_REQUEST",
"message": "Only pending carts can be checked out",
"meta": { "cartId": "cart_19", "status": "paid" }
}
}
Error log — downstream with withDebug
throw AppError.serverError('Inventory service unavailable')
.withInnerError(error)
.withMeta({ orderId: 'ord_441', sku: 'SKU-1042' })
.withDebug({
downstream: { method: 'POST', path: '/v1/reserve', status: 503, latencyMs: 2104 },
attempt: 2,
})
Logged
{
"level": "fatal",
"msg": "Inventory service unavailable",
"requestId": "req_22ab…",
"http": { "status": 500 },
"error": {
"id": "err_01L…",
"code": "app.internal_server_error",
"severity": "SERVER_ERROR",
"message": "Inventory service unavailable",
"meta": { "orderId": "ord_441", "sku": "SKU-1042" },
"debug": {
"downstream": { "method": "POST", "path": "/v1/reserve", "status": 503, "latencyMs": 2104 },
"attempt": 2
},
"innerError": {
"name": "FetchError",
"message": "socket hang up"
}
}
}
Error log — unexpected server failure
{
"level": "fatal",
"msg": "Payment provider unavailable",
"requestId": "req_77e1…",
"http": { "status": 500 },
"error": {
"id": "err_01M…",
"code": "app.internal_server_error",
"severity": "SERVER_ERROR",
"message": "Payment provider unavailable",
"meta": { "orderId": "ord_441" },
"innerError": {
"name": "FetchError",
"message": "socket hang up"
}
}
}
Neighborhood prompt for Cursor
-
You
Symptom: Client shows paymentStatus=paid for ord_441, but the orders table is still pending. Customer was charged once. Do this yourself — do not wait for me to paste more logs: 1. Load application logs for requestId=req_8f3a and summarize input + exact response (and any warn/fatal error objects with meta/debug) 2. Query the neighborhood: same orderId / idempotencyKey across api + worker in a ±30m window 3. Find the write path that left DB pending while the HTTP response said paid 4. Fix that path; if you throw, use AppError.*.withMeta({ orderId, ... }) and log the AppError at the edge (warn vs fatal by severity) Access: - local: docker compose logs or the log script in your API package - ids: requestId=req_8f3a orderId=ord_441 idempotencyKey=idem_77 Constraints: - Read-only against prod-shaped data; redact secrets - Small fix; review before commit
You gave access and a neighborhood. The agent investigates. You review the diff.
Operating habits
Write logs as if the next reader is cold
Assume Cursor did not write the feature and was not on the call. Put the exact response (or its critical fields) and the evidence that produced it in the record.
Prefer “here is the id” over “here is everything I remember”
Your memory is lossy. The log store is not. Point the agent at ids; let it pull context and neighbors.
Teach the agent how to query
A short rule helps: On production bugs, fetch logs by requestId, then query ±30m by business key across related services before proposing a fix.
Still redact
Access without redaction is how useful logging becomes an incident. Full context means full decision/request context, not a credential dump. Put sensitive ops detail in debug only when needed — not PII (names, emails, phone numbers, payment identifiers, and the like stay out or masked). Rely on logger redaction as a backstop, not the plan.
Still bound the fix
Better logs improve diagnosis. They do not excuse a 40-file refactor. Small blast radius; review before commit.
The pattern you can reuse
I call it:
Write application logs so a coding agent can understand the exact response in one read — and let that agent query the neighborhood when the story is incomplete.
Try this:
- On every request/job, log input summary, key business ids, and exact response (or structured outcome) under a
requestId. - Throw typed errors with stable codes; attach
.withMeta({ ... })for client-safe facts and.withDebug({ ... })for log-only detail. - At the edge, log the
AppErrorobject (warnvsfatalby severity) so meta/debug land in the same line Cursor will read. - Make those logs reachable from the repo workflow (script, CLI, MCP, log file path).
- On failure, give Cursor the id and symptom — not a memoir.
- Tell it to load the request, then query neighbors before fixing.
- After the fix, ask: Would an agent have understood this from the log alone? If no, add the missing field (usually more
meta).
What this is not: status-only logging, logs trapped in a UI only humans click, or pasting random fragments and hoping the model guesses the rest. And it is not a guide to instrumenting product AI agents — it is how any application should log so AI agents can help you debug.
Conclusion
The useful change is not a new library. It is a different reader of the same application logs.
Write enough context that a coding agent can review a request and immediately understand the exact response. Throw errors that carry meta and debug so the failure is self-describing in the log. Then give that agent access to the log store so it can query the neighborhood — same business key, surrounding requests, sibling workers — instead of waiting for you to act as a slow, lossy API.
Log for comprehension. Open for investigation. Do that for any application, and Cursor guesses less about production — it has evidence to read.