GraphJSON can turn structured application logs into fast operational queries, dashboards, and alerts. The useful unit is not a line of prose. It is one bounded JSON record that describes a meaningful occurrence with consistent fields.
Use this guide for application and service diagnostics. Keep distributed trace waterfalls, profiles, raw stack archives, and guaranteed paging in the specialized systems that own those workflows.
Separate logs from product events#
Product events describe durable business facts:
invoice_paid
report_published
workspace_created
Operational logs describe what a system observed while doing work:
dependency_timeout
job_retry_scheduled
request_completed
Put them in different collections when they need different owners, sensitivity, volume, or retention:
product_events
application_logs
security_audit_events
Do not let a high-volume debug stream determine the retention or cost of business events. Do not treat an application log as the authoritative billing, security, or order ledger.
Use one stable log contract#
A practical record:
{
"event": "application_log",
"event_id": "log_01J...",
"schema_version": 1,
"severity": "error",
"service": "billing-api",
"environment": "production",
"release": "2026.07.26.3",
"operation": "invoice.finalize",
"message_template": "billing_provider_timeout",
"error_type": "ProviderTimeout",
"error_fingerprint": "provider_timeout:invoice_finalize:v2",
"retryable": true,
"duration_ms": 5032,
"route": "POST /v1/invoices/:id/finalize",
"status_code": 503,
"request_id": "req_01J...",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"span_id": "00f067aa0ba902b7",
"account_id": "acct_7"
}
Send occurrence time as the GraphJSON request timestamp. Keep
insert_timestamp or observed_at only when you need to measure delivery
delay.
Required fields should usually include:
| Field | Purpose |
|---|---|
severity |
Small controlled operational level |
service |
Producing deployable service |
environment |
Production, staging, or development |
release |
Immutable deployed version |
operation |
Stable operation or workflow name |
message_template |
Low-cardinality occurrence class |
event_id |
Retry and investigation identity |
schema_version |
Contract evolution |
Add request, trace, account, job, and provider identifiers only where they are available and safe.
Keep severity small and meaningful#
Use a controlled set:
debug
info
warn
error
fatal
Define severity by expected operational impact, not by which logging method a developer happened to call.
debug: temporary diagnostic detail; normally disabled or heavily sampledinfo: expected lifecycle occurrence worth queryingwarn: handled degradation or recoverable unexpected behaviorerror: an operation failed or exhausted its recovery pathfatal: the process or essential subsystem cannot continue
Do not promote every caught exception to error. A handled client cancellation
or expected validation failure can be informational. Conversely, a failed
business operation can deserve error even when no language exception was
thrown.
If you already emit OpenTelemetry logs, map its timestamp, severity, resource, trace, span, body, and attributes into a deliberately smaller GraphJSON contract. The OpenTelemetry logs data model is a useful source vocabulary; it is not a requirement to copy every attribute.
Prefer templates over free-form messages#
This:
message_template = billing_provider_timeout
provider = stripe
operation = invoice.finalize
is easier to group than:
Stripe timed out while finalizing invoice inv_391 for Acme
Keep variable values in separate fields. A free-form message can still help
with a human investigation, but:
- remove identifiers and secrets
- cap its length
- never split a chart by it
- do not depend on exact wording in an alert
- assume it can create high cardinality
Use error_type for a stable class and error_fingerprint for a reviewed
grouping rule. Do not fingerprint the entire message when it includes IDs,
timestamps, or random values.
Correlate without copying payloads#
Useful correlation fields:
| Field | Join it to |
|---|---|
request_id |
Gateway and application request logs |
trace_id |
Distributed tracing system |
span_id |
Specific trace operation |
job_id |
Queue or background-job system |
event_id |
One log delivery attempt or occurrence |
account_id |
Authorized customer investigation |
release |
Deployment history |
Trace and span IDs are opaque correlation values, not user identities. Keep
them as strings. If span_id is present, include trace_id too.
Do not put request bodies, response bodies, authorization headers, cookies, access tokens, card details, prompts, or complete customer records into a log merely to avoid visiting the source system.
Treat exceptions as sensitive#
Exception messages and stack traces can contain:
- file paths
- SQL values
- customer content
- URLs and query parameters
- credentials embedded in library errors
Prefer:
{
"error_type": "ProviderTimeout",
"error_code": "billing_provider_timeout",
"error_fingerprint": "provider_timeout:invoice_finalize:v2"
}
If you retain a redacted stack excerpt, keep it bounded and in a short-retention collection. GraphJSON accepts at most 10,000 serialized characters per event; that limit is a guardrail, not a target payload size.
Instrument requests once#
For request analytics, emit one terminal record after the status and duration are known:
{
"event": "request_completed",
"severity": "info",
"service": "reporting-api",
"environment": "production",
"release": "2026.07.26.3",
"route": "GET /v1/reports/:id",
"method": "GET",
"status_code": 200,
"status_family": "2xx",
"duration_ms": 184,
"request_id": "req_01J..."
}
Use a normalized route template, not a raw path containing customer IDs or query strings. Do not emit separate “request started” and “request succeeded” records unless the start record answers a distinct operational question.
Deliver with the right durability#
Debug and routine request logs can often tolerate loss during a process crash. Billing corrections, security audit facts, and irreversible workflow outcomes usually cannot.
Classify before building:
| Class | Delivery pattern |
|---|---|
| Disposable diagnostic | Bounded in-memory buffer or direct non-blocking send |
| Operationally important | Durable queue with bounded retries |
| Authoritative business/security fact | Transactional ledger first; GraphJSON receives an analytical copy |
Batch up to 50 records per /api/bulk-log request. Monitor queue age, rejected
events, and drops. Keep live product ingestion capacity available during a log
spike.
Sample intentionally#
Sampling changes the denominator. Record:
- sampling rule version
- sampling probability or class
- whether the record is retained deterministically
Prefer deterministic sampling by a stable request or trace ID so all related records make the same decision.
Do not use a sampled stream to calculate exact customer usage, financial totals, security audit completeness, or per-account quotas. When sampling request logs, either preserve unsampled counters elsewhere or weight only analyses whose sampling design supports it.
Consider retaining:
- all
fatalrecords - all or most
errorrecords - a reviewed fraction of successful high-volume requests
- short-lived debug records only during an investigation
Query errors by release#
SELECT
JSONExtractString(json, 'release') AS release,
JSONExtractString(json, 'service') AS service,
count() AS errors
FROM application_logs
WHERE
JSONExtractString(json, 'severity') IN ('error', 'fatal')
AND toDateTime(timestamp) >= subtractHours(now(), 24)
GROUP BY release, service
ORDER BY errors DESC
Always compare error volume with request volume. A release serving more traffic can produce more errors while having a lower error rate.
Rank error fingerprints#
SELECT
JSONExtractString(json, 'error_fingerprint') AS fingerprint,
JSONExtractString(json, 'service') AS service,
count() AS occurrences,
uniqExact(JSONExtractString(json, 'request_id')) AS requests,
max(toDateTime(timestamp)) AS last_seen
FROM application_logs
WHERE
JSONExtractString(json, 'severity') IN ('error', 'fatal')
AND JSONExtractString(json, 'error_fingerprint') != ''
AND toDateTime(timestamp) >= subtractDays(now(), 7)
GROUP BY fingerprint, service
ORDER BY occurrences DESC
LIMIT 50
A fingerprint groups known failure classes. It does not prove that every occurrence has one root cause. Verify representative records before merging issues.
Investigate one request#
SELECT
toDateTime(timestamp) AS occurred_at,
JSONExtractString(json, 'service') AS service,
JSONExtractString(json, 'operation') AS operation,
JSONExtractString(json, 'severity') AS severity,
JSONExtractString(json, 'message_template') AS message_template,
JSONExtractString(json, 'error_type') AS error_type
FROM application_logs
WHERE JSONExtractString(json, 'request_id') = 'req_01J...'
ORDER BY timestamp
LIMIT 200
Run request-level queries only in an authorized operational workflow. A request ID is not permission to expose another customer’s activity.
Build the operations dashboard#
Start with:
- request volume by service
- 5xx rate by normalized route
- p50 and p95 request duration
- errors by service and release
- top error fingerprints
- queue age and dropped-log count from the producer
- last observed production record
Keep ingestion health separate from service health. “No errors” can mean the service is healthy, the logger is broken, or the collection stopped receiving events.
Use GraphJSON alerts for focused threshold or comparison signals. Keep paging guarantees, acknowledgements, escalations, and on-call routing in an incident management system.
Choose retention by purpose#
Example:
request summaries: 30 days
error records: 90 days
debug investigation: 7 days
security audit facts: policy-defined authoritative system
product events: separate policy
Measure daily volume and payload size before choosing an indefinite period. High-cardinality identifiers are useful for investigation but expensive to keep forever.
Production checklist#
- Product facts, application logs, and security records have explicit boundaries.
- Severity, service, operation, route, release, and fingerprint are controlled fields.
- Variable values are separate from message templates.
- Raw bodies, headers, tokens, cookies, and customer content are excluded.
- Trace and request IDs are opaque and bounded.
- Sampling rules and analytical consequences are documented.
- Important records use a durable delivery path.
- Dashboard totals distinguish service health from ingestion health.
- Retention reflects sensitivity, volume, and investigation needs.
- Operational alerts have a separate acknowledgement and escalation owner.
Continue with Measure release impact, SLOs, error budgets, and alerts, Background jobs and queue analytics, Security audit event analytics, and Customer-support account timelines.