How to parse json structured logs with jsonpath expressions
- Step 1Capture the relevant log lines — Copy the JSON log lines from your terminal, log file, or platform export. Each line should be one complete JSON object.
- Step 2Wrap the lines in a JSON array — Surround the lines with
[and]and put a comma between each object. This turns NDJSON into a single valid JSON document the tool can parse. Skipping this step causes a parse error. - Step 3Drop or paste the array — Provide the wrapped batch as one document. The top-level value is now an array, so paths start from
$[*]or$[?(...)]. - Step 4Write the extraction or filter expression — Use
$[*].msgto list every message,$[?(@.level == 'error')]to keep error records, or$..err.messageto reach nested error text. Filters take one operator at a time — there is no&&/||. - Step 5Read the match count and inspect — The panel shows the matched nodes and a count.
0 matchesafter a filter means the condition matched nothing (or the field path is wrong) — re-check the exact key names against one raw line. - Step 6Translate the path into your query language — Use the confirmed key path in your observability platform: Loki
| json | level="error", Datadog@level:error, or CloudWatch Insightsfilter level = "error". JSONPath itself isn't used there, but the field names transfer directly.
Turning log formats into a queryable document
What the tool accepts and what it does with each. The key constraint: input must be one valid JSON document.
| Input shape | Parses? | How to query / what to do |
|---|---|---|
| Single JSON log object | Yes | Query directly: $.msg, $.err.message, $.traceId |
| Raw NDJSON (one object per line) | No — parse error | Wrap in [ ... ] with commas between lines, then query $[*]... |
JSON array of log records [ {...}, {...} ] | Yes | Use array paths: $[*].field, $[?(@.k == v)], slices $[0:10] |
| Pretty-printed (multi-line) single object | Yes | Whitespace is fine inside one document; only multiple top-level values break it |
Lines with a trailing comma after the last ] | No — invalid JSON | Remove trailing commas; run through json-format-fixer if unsure |
Common log fields and the path that extracts them
Field names follow Pino/Bunyan/Winston conventions; adjust to your logger. Assumes the batch is wrapped as a top-level array.
| You want | JSONPath | Returns |
|---|---|---|
| Every message | $[*].msg | Array of message strings across all records |
| Only error-level records | $[?(@.level == 'error')] | Full record objects where level equals error |
| Slow requests (>1s) | $[?(@.responseTime > 1000)] | Records whose responseTime exceeds 1000 |
| Nested error text at any depth | $..message | Every message key, top-level or under err |
| First 20 records (slice) | $[0:20] | A window of the batch for a quick scan |
| Records that even have a trace id | $[?(@.traceId)] | Records where the traceId key is present |
Cookbook
Real structured-log batches, wrapped as JSON arrays, with the path that pulls each field. Trace ids and user ids are anonymised.
Wrap NDJSON, then list every message
ExampleThe captured lines are NDJSON, which won't parse on their own. After wrapping in [ ... ] the batch is one array and $[*].msg projects every message.
Step 1 — wrap the lines:
[
{ "level": "info", "msg": "request ok" },
{ "level": "error", "msg": "db timeout" }
]
Path: $[*].msg
Output [matches: 2]:
[ "request ok", "db timeout" ]Keep only error-level records
ExampleA single-operator filter on the wrapped array returns the whole record for each error line, so you can read every field of the failing requests.
Path: $[?(@.level == 'error')]
Input (wrapped):
[
{ "level": "info", "msg": "ok" },
{ "level": "error", "msg": "db timeout", "traceId": "t-9" }
]
Output [matches: 1]:
{ "level": "error", "msg": "db timeout", "traceId": "t-9" }Reach a nested error message at any depth
ExampleSome loggers nest the real message under err. Recursive descent finds every message key without you mapping the exact structure.
Path: $..message
Input (wrapped):
[
{ "msg": "handled", "err": { "message": "ECONNRESET" } }
]
Output [matches: 1]:
"ECONNRESET"Pull every trace id for slow requests
ExampleFilters take one predicate, so you can select slow requests OR errors but not both at once. Here we select slow requests and project the trace id.
Path: $[?(@.responseTime > 1000)].traceId
Input (wrapped):
[
{ "responseTime": 120, "traceId": "t-1" },
{ "responseTime": 2400, "traceId": "t-2" }
]
Output [matches: 1]:
"t-2"Slice the first window of a large capture
ExampleWhen you pasted thousands of lines, a slice gives a quick peek without scrolling — useful to confirm the field names before running a heavier filter.
Path: $[0:3]
Input (wrapped, 500 records)
Output [matches: 3]:
[ {first record}, {second record}, {third record} ]Errors and edge cases
Real errors and silent failures sourced from each platform's own documentation. Match the wording to the row, fix what the row says to fix.
Pasting raw NDJSON without wrapping
Parse error{...}\n{...}\n{...} is several top-level values, so JSON.parse throws Unexpected non-whitespace character after JSON. Wrap the lines in [ ... ] with commas between them first — that is the single most common log mistake here.
Compound condition like error AND slow
0 matches$[?(@.level == 'error' && @.duration > 1000)] is unsupported and returns nothing. Filter on one condition (say errors), then scan the result, or apply the second condition in your platform query.
Filtering before the batch is an array
0 matchesIf the document is a single object (one log line) and you use $[*] or $[?(...)], array-style paths won't match it. Query a single line with object paths ($.msg) or wrap multiple lines as an array.
Field present but set to null
Matched$[?(@.userId)] keeps a record even when userId: null, because existence checks presence, not value. Add $[?(@.userId != null)] to drop nulls.
Level stored as a number (Pino default)
By designPino writes level as an integer (50 = error) by default, not the word error. $[?(@.level == 'error')] then matches nothing — filter numerically with $[?(@.level >= 50)] or compare to the exact number.
Trailing comma after the last record
Invalid JSON[ {...}, {...}, ] is invalid JSON — strict JSON.parse rejects the trailing comma. Remove it, or run the batch through the json-format-fixer.
Batch larger than the free limit
BlockedFree tier caps input at 2 MB. A long capture window can exceed that; Pro raises the ceiling to 100 MB. Otherwise wrap a smaller slice of the lines you actually need.
Expecting frequency counts of values
Not supportedThe tool extracts and filters; it does not group or count occurrences of a value. To tally the most common error messages, extract $[*].msg, then paste the result into a spreadsheet or pipe through jq's group_by.
Mixed schemas across log lines
0 matchesIf some records lack the field you project, those positions simply produce no node — the result is shorter than the record count. Use an existence filter first ($[?(@.field)]) to keep only records that have it.
Frequently asked questions
Why won't my pasted log file parse?
Structured logs are usually NDJSON — one JSON object per line — which is multiple top-level values, not a single document. JSON.parse rejects that. Wrap the lines in a JSON array ([ {...}, {...} ]) and it parses as one document.
How do I keep only error records?
After wrapping the batch as an array, use $[?(@.level == 'error')]. If your logger stores level as a number (Pino uses 50 for error), filter numerically: $[?(@.level >= 50)].
Can I filter on two conditions at once?
No — filters accept a single predicate, and &&/|| are unsupported (they return 0 matches). Apply the most selective condition here, then narrow the rest downstream.
Does the tool count how often each value appears?
No frequency counting is built in. Extract the values (e.g. $[*].msg), then count them in a spreadsheet or with jq's group_by. The tool only extracts and filters.
How do I reach a message nested under `err`?
Use recursive descent: $..message finds every message key at any depth, so it works whether the text is top-level or under an err object.
Is my log data uploaded?
No. Parsing and evaluation happen entirely in your browser. Trace ids, user ids, and message contents are never sent to JAD Apps servers.
Can I use the JSONPath directly in Datadog or Loki?
Not as-is — those platforms have their own query syntax. But the field paths you confirm here translate directly: a level field becomes @level in Datadog or a json label filter in Loki.
What if records have different shapes?
Projecting a field only yields a node where that field exists, so mixed schemas give a result shorter than the record count. Filter with $[?(@.field)] first to keep only records that have the field.
Existence filter matched a line with a null field — why?
[?(@.x)] tests that the key is present, not that it's truthy, so x: null still matches. Use [?(@.x != null)] to exclude nulls.
How large a batch can I paste?
Up to 2 MB on the free tier and 100 MB on Pro. For most incidents a wrapped slice of the relevant lines is well within the free limit.
Can I extract from the file without wrapping if I drop a `.jsonl` file?
The dropzone accepts the extension, but the extractor still runs JSON.parse on the whole file. A genuine multi-record .jsonl/.ndjson will fail to parse — wrap the records in an array first.
How do I turn these logs into a CSV for a report?
Extract the fields you need, paste the array into json-to-csv, or flatten nested records first with json-flattener.
Privacy first
Conversion runs locally in your browser. No file is uploaded — only metadata counters are saved for signed-in dashboard stats.