# Logging and monitoring setup in Cursor

URL: https://developertoolkit.ai/en/cursor-ide/operate/monitoring/
Description: Use Cursor's agent to generate structured logs, Prometheus metrics, OpenTelemetry tracing and alert rules, then prove each layer works with tests and promtool.

Logging and monitoring setup in Cursor means having Cursor's agent generate four observability layers plus dashboards for a service: structured JSON logs, Prometheus metrics, OpenTelemetry traces, and alert rules. The agent writes each layer quickly because the conventions are well documented; trust a layer only once a test, `promtool`, or a trace smoke test proves it.

This page is for developers who own a Node.js or TypeScript service and the tech leads who set the observability conventions for a team. The pager fires at 02:00 for "high error rate on orders API". The logs say `Internal Server Error` with no request ID, no route, and no user, and nothing ties the spike on the dashboard to a line of code. The fix is not more logging. It is logs, metrics, and traces that share identifiers, and alerts that fire on user-facing symptoms.

## What you get from an agent-built observability stack

- A project rule that fixes your field names, label sets, and log levels before the agent writes any code.
- A `pino` logger with request context, redaction, and `trace_id` on every line.
- `prom-client` metrics with a bounded label set, plus a test that fails when someone adds an unbounded label.
- OpenTelemetry tracing that loads before your app and propagates W3C `traceparent` across services.
- Prometheus alert rules with unit tests in `promtool`, so you know each alert fires before production does.
- Cursor connected to Sentry or Grafana through MCP, so the agent can read real telemetry when you debug.

The examples use Express on Node.js. The prompts carry over to Fastify, NestJS, or Next.js route handlers once you swap the framework name and the route-pattern accessor (for example, Fastify's `routeOptions.url`).

## In what order should the agent build observability?

Build the layers in dependency order: conventions, then logs, metrics, traces, alerts, and dashboards last. Alerts query metric names, and dashboards query both, so generating them first gives you rules that point at series that do not exist. Each step below ends with a gate. Do not start the next step until the gate is green.

1. **Write the conventions down as a project rule.** Without one, the agent picks field names per file: `requestId` in the middleware, `req_id` in the orders service, `request_id` in the tests. A rule makes every later prompt consistent. See [project rules in Cursor](/en/cursor-ide/quick-start/project-rules/) for where rules live and how they load.

2. **Ask for a plan before code.** Switch the agent to Plan Mode and have it inventory what already exists. Plan Mode "creates detailed implementation plans before writing any code" (Cursor docs, checked 2026-08-28), which keeps the agent from bolting a second logger onto a service that already has one.

3. **Generate one layer per conversation.** A fresh conversation per layer keeps the context small and the diff reviewable. Commit after each gate passes, so a bad layer reverts cleanly with `git revert`.

**Copy-paste prompt: observability conventions rule**

```text
Create a Cursor project rule named "observability" that applies to every
file under src/. It must state these conventions, and nothing else:

Logging
- Logger: the pino instance exported from src/lib/logger.ts. Never console.log.
- Required fields on every line: time (ISO 8601), level, service, env,
  request_id, trace_id, span_id. Use snake_case for all field names.
- Levels: error = a request failed or data was lost; warn = degraded but
  served; info = one line per business event; debug = off in production.
- Never log: password, token, authorization, cookie, card numbers, full
  request bodies.

Metrics
- Library: prom-client, registry exported from src/lib/metrics.ts.
- Allowed label keys: method, route, status_class, payment_method, provider,
  result. The route label is the Express route pattern (/orders/:id), never
  the raw URL. Never use user_id, order_id, email or request_id as a label.
- Names: snake_case, unit suffix (_seconds, _bytes), counters end in _total.

Tracing
- OpenTelemetry. Use current HTTP semantic conventions
  (http.request.method, http.route, http.response.status_code), not the
  deprecated http.method / http.status_code.

Before writing the rule, read the existing code and list any place that
already breaks these conventions. Do not fix them yet.
```

## Generate structured logging with request context

Logs are the first layer because every other layer references their identifiers. The prompt below asks for the logger, the request middleware, and the test that proves both in one pass.

**Copy-paste prompt: pino logger with request context**

```text
Follow the observability project rule.

1. Create src/lib/logger.ts using pino:
   - base fields: service (SERVICE_NAME), env (NODE_ENV)
   - level from LOG_LEVEL, default "info"
   - redact: password, token, authorization, cookie, and the same keys
     nested under req.headers and body
   - pretty output only when NODE_ENV=development (pino-pretty transport);
     single-line JSON otherwise
   - a mixin that adds request_id from AsyncLocalStorage and trace_id /
     span_id from the active OpenTelemetry span, when present

2. Create src/middleware/request-context.ts (Express):
   - request_id = incoming x-request-id header if it is a UUID, else a new UUID
   - store it in AsyncLocalStorage for the whole request
   - echo it back as the x-request-id response header
   - log one "request completed" line with method, route pattern,
     status_code and duration_ms; skip /health and /ready

3. Create tests/logger.test.ts. Capture pino output into an in-memory
   stream and assert:
   - every line parses as JSON and has the required fields from the rule
   - a logged object with password and headers.authorization comes out
     with both values replaced by "[Redacted]"
   - two concurrent requests keep their own request_id (no leakage
     through AsyncLocalStorage)

Run the tests and show me one captured production-mode log line.
```

**Gate:** the three tests pass, and the sample line contains `request_id` and no secret. The concurrency test is the one that matters: `AsyncLocalStorage` leakage between requests is invisible in a single-request manual check.

To add business context later, ask for a child logger per unit of work rather than new fields in every call: "In `src/services/orders.ts`, create a child logger with `order_id` and `customer_id` at the start of `processOrder` and use it for every log line in that function." Business IDs belong in logs, where each line is one event, and never in metric labels.

## Add Prometheus metrics without a cardinality explosion

Metrics answer "how often and how slow" in aggregate. The label set is the real design decision. Each distinct combination of label values creates a new time series, so one `user_id` label turns a counter into a series per user.

**Copy-paste prompt: prom-client metrics with a cardinality guard**

```text
Follow the observability project rule.

1. Create src/lib/metrics.ts with prom-client:
   - call collectDefaultMetrics() once; do not redefine its Node.js metrics
   - http_requests_total (counter: method, route, status_class)
   - http_request_duration_seconds (histogram: method, route;
     buckets 0.01, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5)
   - orders_created_total (counter: payment_method)
   - payment_attempts_total (counter: provider, result)
   - helpers recordOrderCreated(paymentMethod) and
     recordPaymentAttempt(provider, result)

2. Middleware that records the HTTP metrics on res "finish", using
   req.baseUrl + req.route.path as the route label, and "unmatched" when
   Express matched no route. Expose GET /metrics; do not record /metrics
   or /health themselves.

3. tests/metrics.test.ts:
   - fire 50 requests to /orders/<random id> and assert
     http_requests_total has exactly one series for route="/orders/:id"
   - request an unknown path 20 times with different URLs and assert
     they all land in route="unmatched"
   - parse /metrics output and, for metrics this service defines
     (exclude process_* and nodejs_*), fail if any label key other than
     the allowed list in the rule or the histogram "le" label appears

Run the tests and paste the /metrics lines for http_requests_total.
```

**Gate:** the series count test passes. The `unmatched` case catches the most common generated bug: using `req.path` or `req.originalUrl` as the label, which works in a demo and creates one series per URL under a scanner's traffic.

## Wire OpenTelemetry tracing across services

Tracing links one user request across services and ties it back to the log lines through `trace_id`. The Node.js SDK has one hard rule the agent often misses: it must load before `express`, `http`, and database clients are imported, or auto-instrumentation patches nothing and you get empty traces with no error.

**Copy-paste prompt: OpenTelemetry Node.js setup**

```text
Follow the observability project rule.

1. Create src/instrumentation.ts using @opentelemetry/sdk-node and
   @opentelemetry/auto-instrumentations-node:
   - service.name from SERVICE_NAME
   - OTLP HTTP trace exporter; endpoint from OTEL_EXPORTER_OTLP_ENDPOINT
   - ConsoleSpanExporter when NODE_ENV=development
   - disable the fs instrumentation (too noisy)
   - shut the SDK down on SIGTERM so the last spans are flushed

2. Load it before the app: change the start script to
   node --import ./dist/instrumentation.js dist/server.js
   (or --require for CommonJS). Do not import it from server.ts.
   If the built app is ESM, also pass the OpenTelemetry loader hook:
   node --experimental-loader=@opentelemetry/instrumentation/hook.mjs \
     --import ./dist/instrumentation.js dist/server.js
   Without the hook, ESM imports of express and http are never patched.

3. Do not write custom middleware that creates server spans. The HTTP and
   Express auto-instrumentations already do. Only add a withSpan(name, fn)
   helper for business steps such as "reserve-inventory".

4. tests/tracing.smoke.test.ts: start the app with an in-memory span
   exporter, send a request with the header
   traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
   and assert that
   - the server span has trace_id 4bf92f3577b34da6a3ce929d0e0e4736
   - the "request completed" log line has the same trace_id
   - an outgoing fetch to a stub service carries a traceparent header
     with that trace_id
```

**Gate:** the smoke test passes. It proves three things you would otherwise check by hand across three tools: the SDK loaded in time, the logger reads the active span, and propagation works on outgoing calls. The package names above are current on npm as of 2026-09-26 (`@opentelemetry/sdk-node` 0.222.0, `@opentelemetry/auto-instrumentations-node` 0.80.0).

## Generate alert rules and prove they fire

Alert on symptoms users feel, such as error ratio and latency, not on causes such as CPU. The agent writes Prometheus rule syntax well. The gap is thresholds that were never exercised. `promtool test rules` closes that gap: it feeds synthetic series into the rules and asserts which alerts fire at which minute.

**Copy-paste prompt: alert rules with promtool unit tests**

```text
Use only metric names that exist in src/lib/metrics.ts.

1. monitoring/alerts/api.yml (Prometheus rule format):
   - ApiErrorRatioHigh: 5xx / all requests > 0.05 for 5m, severity page
   - ApiLatencyP99High: histogram_quantile(0.99, ...) > 2s for 10m,
     severity ticket
   - ApiNoTraffic: absent or zero request rate for 5m, severity page
   Every alert has labels.severity and annotations summary, description
   and runbook_url (https://runbooks.example.internal/<alert-name>).

2. monitoring/alerts/business.yml:
   - PaymentFailureRatioHigh: failed / all payment_attempts_total > 0.10
     for 10m, severity page

3. monitoring/alerts/tests/api_test.yml with promtool unit tests:
   - a series at 1% errors for 15m: ApiErrorRatioHigh does NOT fire
   - a series at 10% errors: does NOT fire at 4m; fires by 7m
   - zero traffic: ApiNoTraffic fires
   Derive each eval_time from the rule's for: plus its rate() window,
   and explain the choice in a comment.

Then give me the exact commands to run promtool check rules and
promtool test rules in the prom/prometheus Docker image.
```

Run the gate in a terminal. The `prom/prometheus` image ships `promtool`, so you do not need a local Prometheus install:

```bash
docker run --rm -v "$PWD/monitoring:/m" --entrypoint promtool \
  prom/prometheus check rules /m/alerts/api.yml /m/alerts/business.yml
docker run --rm -v "$PWD/monitoring:/m" --entrypoint promtool \
  prom/prometheus test rules /m/alerts/tests/api_test.yml
```

**Gate:** both commands exit 0. Add them to CI next to your unit tests so a later edit to a metric name breaks the build, not the pager. [CI/CD with Cursor](/en/cursor-ide/ship/ci-cd/) shows where that job goes.

Dashboards come last and are the lowest-risk layer, because a wrong panel misleads a person, while a wrong alert wakes one. Ask for a Grafana dashboard JSON with an `environment` and `service` template variable and one row each for traffic, errors, latency, and saturation, and tell the agent to use only the metric names in `src/lib/metrics.ts`. Import it into a staging Grafana and check that every panel returns data.

## Let Cursor read your production telemetry through MCP

Generated instrumentation is half the loop. The other half is the agent reading what that instrumentation reports when something breaks. Cursor reads MCP servers from `.cursor/mcp.json` in the project or `~/.cursor/mcp.json` globally. Sentry's official remote server uses OAuth, so the config holds no secret:

```json
{
  "mcpServers": {
    "sentry": { "url": "https://mcp.sentry.dev/mcp" }
  }
}
```

With that in place, a debugging prompt can start from real data instead of a pasted stack trace:

**Copy-paste prompt: debug from live errors**

```text
Using the Sentry MCP server, find the most frequent unresolved error in
the orders-api project over the last 24 hours. Show me its stack trace and
the request_id and trace_id from the event. Map the top in-app frame to
our source, explain the root cause, and propose a fix with a failing test
that reproduces it first. Do not change code until I approve the plan.
```

For metrics and logs, Grafana Labs publishes an official server (`uvx mcp-grafana`, PyPI 1.6.0 on 2026-09-26) that queries Prometheus and Loki; start it with `--disable-write` so the agent can read dashboards but not change them. Keep its service account token in your environment, never in a committed `mcp.json`. Setup for both servers across tools is in [MCP setup for Cursor](/en/cursor-ide/quick-start/mcp-setup/) and [observability MCP servers](/en/ecosystem/mcp/observability-mcp/).

Cursor Automations can also start a Cloud Agent from a Sentry or PagerDuty event (Cursor docs, checked 2026-10-12). That is useful for a first-pass diagnosis on a pull request, but the fix still goes through your normal tests and review. See [Cloud Agents and Automations](/en/cursor-ide/automate/cloud-agents-automations/) before you wire an alert to an agent.

## How do you verify generated observability code without reading it all?

Each layer has a behavioural check that runs in seconds. Together they replace a line-by-line review of a few hundred lines of instrumentation.

| Layer      | What can go wrong silently                         | The check that catches it                                            |
| ---------- | -------------------------------------------------- | -------------------------------------------------------------------- |
| Logs       | Secrets logged; `request_id` leaks across requests | Redaction and concurrency tests on captured output                   |
| Metrics    | Unbounded labels; wrong route label                | Series-count test and allowed-label-keys test on app-defined metrics |
| Traces     | SDK loads too late; no propagation                 | `traceparent` smoke test with an in-memory exporter                  |
| Alerts     | Rule never fires, or fires on noise                | `promtool test rules` with positive and negative cases               |
| Dashboards | Panels query metrics that do not exist             | Import into staging Grafana; every panel shows data                  |

The developer who opens the pull request owns the tests. The tech lead owns the project rule and signs off on the alert thresholds and severities, because those decide who gets woken up. What you still read by eye is short: the rule file, the label list, and the alert thresholds.

## When generated observability code breaks

**Traces are empty, and nothing errors.** The OpenTelemetry SDK was imported after `express` or `http`, so it patched nothing. A second cause, for an ESM build: the SDK loads first but the loader hook is missing, so ESM imports are never patched. Recovery: move the import to `--import` or `--require` in the start command, add `--experimental-loader=@opentelemetry/instrumentation/hook.mjs` if the app is ESM, as in the tracing prompt, and rerun the smoke test.

**Metrics storage bills jump after a deploy.** A label carries a raw URL, an ID, or an error message. Recovery: query your Prometheus for the series count per metric (`count by (__name__)({__name__=~"http_.*"})`), find the offending label, and add that label key to the forbidden list in the project rule so the next generation does not repeat it. The label-keys test keeps it out afterwards.

**The agent wrote custom span middleware on top of auto-instrumentation.** Every request produces two nested server spans with the same name. Recovery: delete the custom middleware and keep `withSpan` only for business steps. The tracing prompt says this up front because the agent adds it by default.

**Attribute names do not match your backend's queries.** Generated code often uses the deprecated `http.method` and `http.status_code` attributes. Recovery: state the current conventions in the project rule, as in the conventions prompt, and ask the agent to migrate existing spans in one pass.

**Alerts fire every day and get muted.** Thresholds were chosen without data. Recovery: set new alerts to `severity: ticket` for the first two weeks, compare their firing history with real incidents, then promote only the ones that matched to `page`. Add a `promtool` case for every threshold you change.

**Production logs are too verbose.** The agent logged at `info` inside loops. Recovery: grep for logger calls inside `for` and `map` bodies, move them to `debug`, and confirm `LOG_LEVEL` defaults to `info` when unset, so production never logs `debug` unless someone sets it on purpose.

## Where to go next with Cursor and observability

- [Monitoring and observability across tools](/en/workflows/operate/monitoring-observability/) — The same practice with Claude Code and Codex, plus SLOs and dashboards.
- [Incident response with AI](/en/workflows/operate/incident-response/) — Use these logs, traces, and alerts during an incident.
- [Agent observability](/en/workflows/operate/agent-observability/) — Monitor the agents themselves: traces, tool calls, cost, and audit logs.
- [DevOps with Cursor](/en/cursor-ide/ship/devops/) — Deploy Prometheus, Grafana, and the collector as infrastructure as code.
- [Microservices with Cursor](/en/cursor-ide/build/microservices/) — Carry trace propagation through every service boundary.
- [Operate with Cursor](/en/cursor-ide/operate/) — Performance, security, and token management for Cursor in production.
