Structured Logging skill for Codex

JSON logs you can query, correlate and trust. Codex replaces ad hoc printf logs with one JSON event per line, a documented field schema, constant messages with variables in fields, and log levels that mean something to on-call. It binds a request or trace ID into context so every line correlates, redacts tokens and PII at the logger, and checks captured output with a bundled validator script.

  • Observability & SRE
  • $structured-logging
  • 188 lines
  • 3 files
  • ~2,150 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- structured-logging

Installs to ~/.agents/skills/structured-logging/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $structured-logging, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Designs and retrofits structured application logs - one JSON object per line, a stable field schema, honest severity levels, request and trace correlation IDs, and redaction of secrets and personal data - using pino, structlog, Go log/slog, Logback with logstash-logback-encoder or Serilog. Use when logs are unsearchable printf strings, when adding request IDs or trace_id to logs, when choosing log levels, when log volume or cost is too high, or when reviewing logging code for leaked tokens or PII.

Use it when

  • Logs are free text and cannot be filtered by field
  • Requests cannot be followed across services or into background jobs
  • Log levels are inconsistent and errors are drowned in noise
  • A review or incident found secrets or PII in logs

Use something else when

  • You need full tracing with spans and context propagation: use opentelemetry
  • The task is querying logs already in Grafana Loki: use loki-logs
  • You are designing security audit trails and detection logging: use security-logging

How to check the result

Capture a few hundred lines of real output and run scripts/check_logs.py with the required fields; it must report zero non-JSON lines, missing fields, type conflicts or secret-like values.

The mistake it prevents

Interpolating IDs into the message text, which makes every line unique and defeats grouping, counting and alerting.

~/.agents/skills/structured-logging/SKILL.md

--- name: structured-logging description: Designs and retrofits structured application logs - one JSON object per line, a stable field schema, honest severity levels, request and trace… license: MIT ---

Done well means: every line is one machine-parseable event with the same core fields, any request can be followed across services by one ID, levels mean something an on-call engineer can filter on, and no secret or personal identifier ever reaches the log pipeline.

Before changing anything

  1. Read AGENTS.md and find the existing logger: rg -n "getLogger|pino\(|structlog|slog\.|LoggerFactory|Log\.Logger" -g '!**/node_modules/**'.
  2. Find where logs go: stdout collected by an agent (Kubernetes, ECS, systemd), files, or a vendor SDK. Containers should log to stdout; the platform ships it.
  3. Find who reads them: Loki, Elasticsearch/OpenSearch, CloudWatch, Datadog. Field names should match what that backend indexes or maps (ECS for Elastic, OTel names for OTLP pipelines).
  4. Grep for existing correlation: rg -n "request_id|requestId|trace_id|X-Request-ID". Extend what exists rather than adding a second ID.

Field schema

Pick one schema per organisation and write it down (in AGENTS.md or a logging doc). Default core fields:

FieldExampleNotes
timestamp2026-03-14T09:26:53.589ZRFC 3339 UTC with milliseconds; never local time
levelerrorlowercase string from the fixed set below
messagepayment declinedconstant text; no interpolated IDs
servicecheckout-apimatches OTel service.name
env / versionprod, 2026.03.1from deploy metadata, not code
trace_id, span_id32 and 16 hex charsfrom the active OTel span when tracing exists
request_id01HV...generated at the edge if no trace ID exists
eventpayment.declineddot-namespaced, stable name for queries and alerts
error.type, error.message, error.stackonly on failures
duration_ms184numbers as numbers, never "184ms"

Rules: keep the message constant and put variables in fields ({"message":"order created","order_id":"o_123"}, not "order o_123 created"), so grouping and counting work. Use snake_case or dotted keys consistently; never mix userId and user_id. Never reuse a key with different types (status as both 200 and "ok" breaks Elasticsearch mappings).

Levels

LevelMeansWho acts
errorA request or job failed and the user/caller saw it, or data is at riskSomeone should look; often counted by alerts
warnDegraded but handled: retry succeeded, fallback used, limit approachingReviewed in aggregate
infoBusiness-significant state change: started, order placed, job finishedDefault production level
debugDeveloper detail for diagnosisOff in production; enable per service or per request

Hard rules: log an exception once, at the boundary that handles it, not at every layer that rethrows. A 404 or a validation failure from a client is info or warn, not error. Never log at error inside a retry loop that may still succeed.

Implementation by stack

Node (pino, JSON to stdout by default):

import pino from 'pino';
export const log = pino({
  level: process.env.LOG_LEVEL ?? 'info',
  base: { service: 'checkout-api', env: process.env.APP_ENV, version: process.env.APP_VERSION },
  timestamp: pino.stdTimeFunctions.isoTime,
  formatters: { level: (label) => ({ level: label }) },
  redact: { paths: ['req.headers.authorization', 'req.headers.cookie', '*.password', '*.token'], censor: '[REDACTED]' },
});
// per request: const reqLog = log.child({ request_id, trace_id });

Python (structlog over stdlib):

import logging, structlog

structlog.configure(
    processors=[
        structlog.contextvars.merge_contextvars,
        structlog.processors.add_log_level,
        structlog.processors.TimeStamper(fmt="iso", utc=True),
        structlog.processors.dict_tracebacks,
        structlog.processors.JSONRenderer(),
    ],
    wrapper_class=structlog.make_filtering_bound_logger(logging.INFO),
)
log = structlog.get_logger(service="checkout-api")
# middleware: structlog.contextvars.bind_contextvars(request_id=rid, trace_id=tid)

Go (standard library log/slog):

logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
logger = logger.With("service", "checkout-api", "version", version)
logger.InfoContext(ctx, "order created", "order_id", id, "duration_ms", ms)

Java: Logback with net.logstash.logback.encoder.LogstashEncoder and MDC for request_id/trace_id. .NET: Serilog with CompactJsonFormatter, or builder.Logging.AddJsonConsole(). Adding a dependency needs network; ask before installing in a sandbox with network off.

Correlation IDs

  1. At the edge (gateway or first service), accept an incoming traceparent (W3C) or X-Request-ID; generate one if absent. Do not trust arbitrary client-supplied IDs for anything except correlation; cap length and charset.
  2. Bind the ID into request-scoped context (AsyncLocalStorage, contextvars, context.Context, MDC) so every log line in that request carries it without passing it by hand.
  3. Propagate on every outbound call and message: HTTP headers, queue message attributes, job payload metadata. Background jobs log the ID of the request that enqueued them.
  4. Return the ID in the response header so a user’s bug report can quote it.
  5. When OpenTelemetry is present, log trace_id/span_id from the active span instead of inventing a parallel ID; the OTel logging bridges for each language do this automatically.

Redaction and safety

  • Deny by default: log explicit fields, never whole request bodies, headers, ORM objects or config. A toJSON on a user object will eventually include a new sensitive column.
  • Redact at the logger (pino redact, a structlog processor, a slog ReplaceAttr) so one place enforces it. Pipeline-side scrubbing is a second line, not the first.
  • Never log: passwords, tokens, API keys, session cookies, full card numbers, national IDs, full email bodies. Hash or truncate user identifiers if the logs are widely accessible and the use case allows.
  • Escape or encode user input; JSON encoding prevents forged log lines from embedded newlines, plain text logging does not.

Volume and cost

  • Do not log per-iteration inside hot loops; aggregate and log once with a count.
  • Drop health-check and readiness-probe access logs at the source.
  • Sample high-volume success logs (for example 1 in 100 info access lines) but never sample warn/error.
  • A metric answers “how many”; a log answers “what happened to this one”. If a log exists only to be counted, emit a counter instead.

Checklist

  • One logger configured once; JSON to stdout in production, pretty output only in dev
  • Core fields present on every line; message text constant
  • Correlation ID bound per request and propagated to outbound calls and jobs
  • Levels follow the table; each exception logged once
  • Redaction configured and tested with a fake secret
  • Noise sources (probes, loops) removed or sampled

Verification

Capture real output and check it mechanically:

LOG_LEVEL=debug npm start 2>&1 | head -200 > /tmp/sample.jsonl   # or the service's run command
python3 scripts/check_logs.py /tmp/sample.jsonl --require timestamp,level,message,service
python3 scripts/check_logs.py /tmp/sample.jsonl --require timestamp,level,message --require-one trace_id,request_id

The script (standard library only) reports lines that are not JSON, missing required fields, unknown level values, fields whose type changes between lines, and values that look like secrets (bearer tokens, AWS keys, JWTs, card-like numbers, emails). It exits 1 on any finding. Also add a unit test that logs a fake password through the real logger and asserts it is redacted.

Output

When reporting a logging change, include: the schema table as adopted, an example line before and after, where correlation is bound and propagated, the redaction list, and the result of check_logs.py on captured output.

Gotchas

  • Logging libraries that buffer asynchronously (pino transports, async appenders) lose the last lines on crash unless flushed on exit; pino writes synchronously to stdout by default, keep it that way unless throughput demands otherwise.
  • Multi-line stack traces in plain text become many separate events in the collector. JSON keeps the stack in one field.
  • print/console.log calls bypass the logger and its redaction; grep for them.
  • Child loggers created per request are cheap; global mutable logger context is a race in concurrent servers.

What the zip contains

Codex reads bundled files only when a step points to them. Every one is shown here, so you can review it before installing.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Structured Logging"
  short_description: "JSON logs you can query, correlate and trust"
  default_prompt: "$structured-logging Our logs are plain strings and impossible to search in Loki, convert this service to structured JSON logging"
scripts/check_logs.py158 lines, 5.8 KB
#!/usr/bin/env python3
"""Check a JSON-lines log sample for structure, schema and leaked secrets.

Usage:
  python3 scripts/check_logs.py sample.jsonl --require timestamp,level,message
  python3 scripts/check_logs.py sample.jsonl --require-one trace_id,request_id
  cat sample.jsonl | python3 scripts/check_logs.py -

Exits 0 when no findings, 1 when any finding, 2 on usage errors. Standard library only.
"""
import argparse
import json
import re
import sys

DEFAULT_LEVELS = {"trace", "debug", "info", "warn", "warning", "error", "fatal", "critical"}
LEVEL_KEYS = ("level", "severity", "log.level", "severity_text", "levelname")

SECRET_PATTERNS = {
    "bearer token": re.compile(r"\bBearer\s+[A-Za-z0-9\-._~+/]{16,}=*", re.I),
    "jwt": re.compile(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}"),
    "aws access key": re.compile(r"\b(AKIA|ASIA)[0-9A-Z]{16}\b"),
    "private key": re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----"),
    "password field": re.compile(r'"(password|passwd|secret|api_key|apikey)"\s*:\s*"(?!\[REDACTED\]|\*+")[^"]+"', re.I),
    "email": re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"),
}
CARD = re.compile(r"\b(?:\d[ -]?){13,19}\b")


def luhn_ok(digits: str) -> bool:
    total, alt = 0, False
    for ch in reversed(digits):
        d = int(ch)
        if alt:
            d *= 2
            if d > 9:
                d -= 9
        total += d
        alt = not alt
    return total % 10 == 0


def flatten(obj, prefix=""):
    out = {}
    if isinstance(obj, dict):
        for k, v in obj.items():
            key = f"{prefix}.{k}" if prefix else str(k)
            if isinstance(v, dict):
                out[key] = v  # keep the parent so "error" as an object still counts
                out.update(flatten(v, key))
            else:
                out[key] = v
    return out


def type_name(v):
    if v is None:
        return "null"
    if isinstance(v, bool):
        return "bool"
    if isinstance(v, (int, float)):
        return "number"
    if isinstance(v, str):
        return "string"
    if isinstance(v, list):
        return "array"
    return "object"


def main() -> int:
    ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    ap.add_argument("path", help="JSON-lines file, or - for stdin")
    ap.add_argument("--require", default="", help="comma-separated fields every line must have (dotted for nested)")
    ap.add_argument("--require-one", default="", help="comma-separated fields; each line needs at least one")
    ap.add_argument("--levels", default=",".join(sorted(DEFAULT_LEVELS)), help="allowed level values")
    ap.add_argument("--allow-email", action="store_true", help="do not flag email addresses")
    ap.add_argument("--max-report", type=int, default=20, help="max findings printed per category")
    args = ap.parse_args()

    required = [f for f in args.require.split(",") if f]
    one_of = [f for f in args.require_one.split(",") if f]
    levels = {x.strip().lower() for x in args.levels.split(",") if x.strip()}

    try:
        fh = sys.stdin if args.path == "-" else open(args.path, encoding="utf-8", errors="replace")
    except OSError as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 2

    findings: dict[str, list[str]] = {}
    field_types: dict[str, dict[str, int]] = {}
    total = parsed = 0

    def add(cat, msg):
        findings.setdefault(cat, []).append(msg)

    with fh:
        for n, line in enumerate(fh, 1):
            line = line.strip()
            if not line:
                continue
            total += 1
            try:
                obj = json.loads(line)
            except json.JSONDecodeError:
                add("not JSON", f"line {n}: {line[:100]}")
                continue
            if not isinstance(obj, dict):
                add("not a JSON object", f"line {n}")
                continue
            parsed += 1
            flat = flatten(obj)
            for f in required:
                if f not in flat:
                    add("missing required field", f"line {n}: {f}")
            if one_of and not any(f in flat for f in one_of):
                add("missing correlation field", f"line {n}: none of {', '.join(one_of)}")
            for key in LEVEL_KEYS:
                if key in flat and isinstance(flat[key], str):
                    if flat[key].lower() not in levels:
                        add("unknown level", f"line {n}: {key}={flat[key]!r}")
                    break
            for k, v in flat.items():
                if isinstance(v, dict):
                    continue
                field_types.setdefault(k, {}).setdefault(type_name(v), n)
            for name, rx in SECRET_PATTERNS.items():
                if name == "email" and args.allow_email:
                    continue
                if rx.search(line):
                    add("possible secret or PII", f"line {n}: {name}")
            for m in CARD.finditer(line):
                digits = re.sub(r"\D", "", m.group())
                if 13 <= len(digits) <= 19 and luhn_ok(digits):
                    add("possible secret or PII", f"line {n}: card-like number")
                    break

    for k, types in field_types.items():
        real = {t: ln for t, ln in types.items() if t != "null"}
        if len(real) > 1:
            desc = ", ".join(f"{t} (first line {ln})" for t, ln in sorted(real.items()))
            add("field type conflict", f"{k}: {desc}")

    print(f"lines: {total}, parsed JSON objects: {parsed}")
    if not findings:
        print("OK: no findings")
        return 0
    for cat, items in findings.items():
        print(f"\n{cat}: {len(items)}")
        for item in items[: args.max_report]:
            print(f"  {item}")
        if len(items) > args.max_report:
            print(f"  ... {len(items) - args.max_report} more")
    return 1


if __name__ == "__main__":
    sys.exit(main())

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
Just launched

Codex Skills Pro is live

79 premium skills for Codex, each with scripts, references and an evidence report that proves the result.

Bought one by one$882.21
All 79 skills$49
94% off

That's $0.62 a skill, paid once.

Get all 79 for $49Or pick single skills from $4.99 →

Secure checkout by Creem