Log Analysis skill for Claude Code

Count, compare to baseline, then read. Claude pins the question and a UTC window, finds the minute errors departed from normal, and groups messages into normalized signatures so new failures stand out against a baseline period. It follows a single request across services by correlation ID, lines the spike up with deploys, and reports findings with the exact queries and redacted sample lines.

  • namelog-analysis
  • size165 lines · ≈1,800 tokens
  • files2
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Finds signal in application and infrastructure logs — scoping by time window, grouping errors into normalized signatures, comparing against a baseline window, following one request by correlation or trace ID, and querying with jq, grep, awk, journalctl, kubectl logs, Loki LogQL, and CloudWatch Logs Insights — and sets up structured JSON logging that makes this easy. Use when digging through logs to explain errors or latency, when asked "what happened at 14:00", when building log queries or dashboards, or when designing a logging format.

Try asking

  • Go through these logs and tell me what happened between 13:50 and 14:30
  • Write a CloudWatch Logs Insights query for p95 latency by endpoint
  • Our logs are unreadable plain text. Design a structured JSON logging format

Use it when

  • You need to explain errors or slowness from log data
  • A spike needs a start time, scope, and likely trigger
  • You are writing LogQL, Logs Insights, KQL, or jq queries
  • Logs need a structured format with request and trace IDs

Skip it when

  • Users are failing right now and you need to coordinate a response — use incident-response instead
  • You want distributed traces rather than logs — use opentelemetry instead

How to check the result

Every finding cites a query and time range, the top signature is shown to be absent or much rarer in the baseline window, and one traced request demonstrates the failure path end to end.

The common mistake it prevents

Treating the loudest error in the incident window as the cause without checking that it fires at the same rate on a normal day.

~/.claude/skills/log-analysis/SKILL.md

--- name: log-analysis description: Finds signal in application and infrastructure logs — scoping by time window, grouping errors into normalized signatures, comparing against… license: MIT ---

Log analysis is done when you can state what happened, when it started, how many requests or users it affected, and which evidence (queries plus sample lines) supports it, compared against a normal baseline period. A pile of scary-looking errors is not a finding until you know they are new.

Ground rules

  • Fix the question and the time window first (UTC). Unbounded searches waste time and money.
  • Always compare against a baseline: same window yesterday or before the change. Many “errors” happen all day.
  • Count before you read. Group by signature, then read samples from the top groups.
  • Follow one request end to end with its request ID or trace ID before generalizing.
  • Never paste secrets, tokens, or personal data from logs into reports; redact samples.
  • Cloud log queries cost money per GB scanned; narrow by time and log group/stream first.

Workflow

- [ ] 1. Question, time window, affected service(s)
- [ ] 2. Locate the logs and their format
- [ ] 3. Volume over time: find the inflection point
- [ ] 4. Group errors into signatures; diff incident window vs. baseline
- [ ] 5. Trace a few failing requests end to end
- [ ] 6. Correlate with deploys, config changes, dependencies
- [ ] 7. Write findings with queries and redacted samples

2. Locate

WhereCommand
Local filesls -lh /var/log/app/; rotated: *.gz -> zcat, zgrep
systemdjournalctl -u nginx --since "2026-10-19 13:30" --until "2026-10-19 14:30" -o json
Dockerdocker logs --since 1h --timestamps <ctr>
Kuberneteskubectl logs -l app=api -n shop --since=1h --timestamps --prefix --max-log-requests=20
Crashed podkubectl logs <pod> --previous
Loki / CloudWatch / othersSee references/query-cheatsheet.md

Check the format on three lines before writing any pipeline: JSON per line, logfmt, or free text (nginx combined, syslog).

3. Volume over time

# JSON logs: errors per minute
jq -r 'select(.level=="error") | .timestamp[0:16]' app.log | sort | uniq -c

# nginx combined log: 5xx per minute
awk '$9 ~ /^5/ {print substr($4, 2, 17)}' access.log | sort | uniq -c

# Status code distribution
awk '{print $9}' access.log | sort | uniq -c | sort -rn

The first minute where the count departs from baseline is the start time. Note it.

4. Signatures and baseline diff

Normalize variable parts (IDs, numbers, UUIDs, IPs) so identical errors group together:

normalize() {
  sed -E 's/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/<uuid>/g;
          s/[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/<ip>/g;
          s/0x[0-9a-f]+/<hex>/g;
          s/[0-9]+/<n>/g'
}
jq -r 'select(.level=="error") | .msg' incident.log  | normalize | sort | uniq -c | sort -rn > incident.sig
jq -r 'select(.level=="error") | .msg' baseline.log  | normalize | sort | uniq -c | sort -rn > baseline.sig
head -20 incident.sig
# Signatures present now but absent (or rare) in baseline:
awk 'NR==FNR {b[substr($0, index($0,$2))]=$1; next}
     {sig=substr($0, index($0,$2)); if (!(sig in b) || $1 > 5*b[sig]) print}' baseline.sig incident.sig

Other high-value groupings:

jq -r 'select(.status>=500) | .path' app.log | sed -E 's#/[0-9]+#/:id#g' | sort | uniq -c | sort -rn | head
jq -r 'select(.level=="error") | "\(.service) \(.error.type // "none")"' app.log | sort | uniq -c | sort -rn
# Slowest requests
jq -c 'select(.duration_ms > 1000) | {t:.timestamp, path, duration_ms, request_id}' app.log | head
# p50/p95/p99 duration from JSON logs
jq -r '.duration_ms // empty' app.log | sort -n | awk '{a[NR]=$1} END {print "p50",a[int(NR*.5)],"p95",a[int(NR*.95)],"p99",a[int(NR*.99)]}'

5. Follow one request

RID=7f3c2a91
rg -z "$RID" /var/log/app/ /var/log/nginx/                 # every line, every service
jq -c --arg rid "$RID" 'select(.request_id==$rid)' *.log | jq -s 'sort_by(.timestamp)[] | {timestamp, service, level, msg}'

If services share a trace_id, use it to jump into the trace view (OpenTelemetry) for timing; logs explain what, traces explain where time went.

6. Correlate

Line up the inflection point with deploy times, config/flag changes, cron schedules, traffic changes, and dependency status pages. A correlation is a lead, not proof; look for an error signature that directly names the changed component.

Tool choice

NeedTool
JSON linesjq (-c compact, -r raw, select, group_by after -s)
Fast search across many/big filesrg (ripgrep, handles .gz with -z), or LC_ALL=C grep -F
Column-based text logsawk
Live tail with filtering`tail -F file
Many GB, repeated questionsLoad into DuckDB: SELECT ... FROM read_json_auto('app.log')
Centralized logsLogQL / Logs Insights / KQL — see reference

Structured logging standard

When asked to improve logs, emit one JSON object per line with these fields:

{"timestamp":"2026-10-19T14:02:11.483Z","level":"error","service":"checkout",
 "env":"prod","version":"1.14.2","msg":"payment authorization failed",
 "request_id":"7f3c2a91","trace_id":"4bf92f3577b34da6a3ce929d0e0e4736",
 "http":{"method":"POST","path":"/orders","status":502},"duration_ms":2310,
 "error":{"type":"UpstreamTimeout","message":"stripe timeout after 2000ms"}}
  • msg is a constant string; variable data goes in fields (so grouping works).
  • Timestamps in UTC ISO 8601 with milliseconds.
  • Generate a request ID at the edge, propagate it in a header, include it in every line.
  • Levels: error = needs attention, warn = unexpected but handled, info = business events, debug off in prod. One error log per failure, at the boundary that handles it.
  • Never log secrets, tokens, full card numbers, passwords; hash or drop user identifiers according to policy.

Gotchas

  • Mixed time zones between services make correlation lie; convert everything to UTC.
  • Log sampling or rate limiting at the agent drops lines during exactly the spikes you care about; check for “dropped” counters.
  • Multi-line stack traces split into separate lines in naive shippers; grep finds the first line only.
  • kubectl logs returns only the current container’s log and only what is on the node; rotated or evicted pod logs need the central store.
  • grep -c error matches “error_count=0”; anchor on the level field.
  • Errors that also occur in baseline at the same rate are background noise, not the cause.

Output

**Question:** Why did checkout fail between 13:58 and 14:27 UTC?
**Start:** 13:58:40 UTC (errors/min 3 -> 410); baseline same window yesterday: 2–5/min
**Top new signature (92% of errors):** "payment authorization failed" error.type=UpstreamTimeout
  - 11,842 occurrences, all from checkout 1.14.2 pods, none from 1.14.1
**Example request:** request_id 7f3c2a91 — POST /orders -> payments call 2.0 s timeout -> 502
**Correlation:** checkout 1.14.2 deployed 13:58:05; changed payments client timeout 10s -> 2s
**Queries used:** (listed, with time ranges)

Bundled files

Loaded only when a step in the skill needs them. Review them here before installing.

references/query-cheatsheet.md 133 lines · 4.0 KB
# Centralized log query cheat sheet

Read this when logs live in Loki, CloudWatch, Elasticsearch/OpenSearch, Datadog, or
Google Cloud Logging. Always set the time range in the UI or API first.

## Grafana Loki (LogQL)

Stream selectors must use indexed labels; filter content afterwards.

```logql
# Errors for one service
{namespace="shop", app="checkout"} | json | level="error"

# Line filter first (cheap), then parse
{app="checkout"} |= "payment" != "healthz" | json | status >= 500

# Errors per minute by error type (metric query)
sum by (error_type) (
  count_over_time({app="checkout"} | json | level="error" [1m])
)

# Top 10 paths by 5xx count over the range
topk(10, sum by (path) (count_over_time({app="nginx"} | pattern `<ip> - - [<_>] "<method> <path> <_>" <status> <_>` | status=~"5.." [$__range])))

# p95 latency from a numeric field
quantile_over_time(0.95, {app="checkout"} | json | unwrap duration_ms [5m]) by (path)

# Follow one request
{namespace="shop"} |= "7f3c2a91"

# logfmt lines
{app="worker"} | logfmt | level="error" | line_format "{{.ts}} {{.msg}} {{.err}}"
```

Pitfalls: high-cardinality labels (user_id, request_id) as stream labels explode Loki;
keep them in the log body. `|= "text"` is case-sensitive; use `|~ "(?i)text"`.

## AWS CloudWatch Logs Insights

```sql
-- Recent errors
fields @timestamp, @message, @logStream
| filter level = "error"
| sort @timestamp desc
| limit 100

-- Errors per minute
filter level = "error"
| stats count(*) as errors by bin(1m)

-- Top error messages
filter level = "error"
| stats count(*) as n by msg
| sort n desc
| limit 20

-- Latency percentiles by path
filter ispresent(duration_ms)
| stats pct(duration_ms, 50) as p50, pct(duration_ms, 95) as p95, pct(duration_ms, 99) as p99, count(*) as n by path
| sort p95 desc

-- Parse unstructured text
parse @message /status=(?<status>\d+) path=(?<path>\S+)/
| filter status >= 500
| stats count(*) by path

-- Lambda cold starts and memory
filter @type = "REPORT"
| stats count(*) as invocations, sum(strcontains(@message, "Init Duration")) as cold_starts,
        max(@maxMemoryUsed / 1000 / 1000) as max_mem_mb by bin(5m)

-- One request
filter @message like /7f3c2a91/
| sort @timestamp asc
```

CLI:

```bash
aws logs start-query --log-group-names /aws/lambda/checkout \
  --start-time $(date -u -d '1 hour ago' +%s) --end-time $(date -u +%s) \
  --query-string 'filter level="error" | stats count(*) by bin(1m)'
aws logs get-query-results --query-id <id>
aws logs tail /aws/lambda/checkout --since 30m --follow --filter-pattern '"ERROR"'
```

(`date -d` is GNU; on macOS use `date -v-1H +%s`.) Insights bills per GB scanned:
choose specific log groups and the shortest range.

## Elasticsearch / OpenSearch (KQL in Kibana / Dashboards)

```
service.name : "checkout" and log.level : "error" and not url.path : "/healthz"
http.response.status_code >= 500 and url.path : /api/orders*
trace.id : "4bf92f3577b34da6a3ce929d0e0e4736"
```

Use visualizations (date histogram split by `error.type`) for counts; KQL only filters.
For aggregations via API, use a `terms` aggregation on a keyword field, not a text field.

## Datadog Logs

```
service:checkout status:error [email protected]_details.path:/healthz
@http.status_code:[500 TO 599] env:prod
@duration:>1000000000      # duration in nanoseconds in Datadog's standard attribute
trace_id:4bf92f3577b34da6a3ce929d0e0e4736
```

Use "Patterns" view to auto-group messages into signatures, and compare the time range
with the previous period.

## Google Cloud Logging

```
resource.type="k8s_container"
resource.labels.namespace_name="shop"
labels.k8s-pod/app="checkout"
severity>=ERROR
jsonPayload.request_id="7f3c2a91"
timestamp>="2026-10-19T13:50:00Z" AND timestamp<="2026-10-19T14:30:00Z"
```

CLI: `gcloud logging read 'severity>=ERROR AND resource.labels.container_name="checkout"' --freshness=1h --format=json | jq ...`

## Azure Monitor (KQL)

```kusto
ContainerLogV2
| where TimeGenerated between (datetime(2026-10-19 13:50) .. datetime(2026-10-19 14:30))
| where ContainerName == "checkout" and LogLevel == "error"
| summarize count() by bin(TimeGenerated, 1m)
```

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc