Render CLI v2.20.0: logs -o json output is not JSON Lines or array, causing parsing and line counting errors
render logs -o json returns a STREAM of concatenated pretty-printed JSON objects, not JSON Lines and not a JSON array. Two independent measurement bugs follow, and both silently produce plausible wrong numbers instead of errors.
json.loads(stdout)raisesExtra data: line 23 column 2 (char 572)— it parses the first record, then hits the second object.wc -l/len(stdout.splitlines())overcounts by the per-record line count (~22 for a Render app log with alabelsarray). A real 24h pull of 88 records read as 1,942 lines. Nothing errors; you just report a number 22x too large.
Bug 2 is the dangerous one, because it interacts with --limit. The standard "did I hit the cap?" test is "did I get back exactly --limit?" — but --limit counts RECORDS while wc -l counts LINES. A windowed pull whose per-window line counts are 617/45/441/529/133/177 looks comfortably under a 1000 cap when the record counts are actually 28/2/20/24/6/8. You conclude "not capped, this is a real measurement" from the wrong quantity and happen to be right by luck; on a busier day you conclude the same thing and are wrong.
Reproduce:
render logs -r <srv-id> --start <ISO> --end <ISO> --text <token> --limit 1000 -o json --confirmEach record looks like:
{
"id": "1b94648e-...",
"labels": [
{ "name": "resource", "value": "srv-..." },
{ "name": "instance", "value": "srv-...-pp466" },
{ "name": "level", "value": "info" },
{ "name": "type", "value": "app" }
],
"message": "{\"slow_ssr_fetch\":true,\"url\":\"http://...\",\"ms\":2024}",
"timestamp": "2026-08-17T12:25:26.835934237Z"
}with the next { starting immediately after the closing }.
Note the app's own structured payload is a JSON string INSIDE message, so extracting a numeric field is a second, nested parse.
Render CLI v2.20.0.
Parse with a raw_decode loop over the whole stdout, then parse message separately. Decide capped/not-capped from the PARSED RECORD COUNT, never from a line count.
import json, subprocess
_dec = json.JSONDecoder()
def stream_objects(s: str):
"""Yield each top-level JSON object from a concatenated stream."""
i, n = 0, len(s)
while i < n:
while i < n and s[i] in " \n\r\t":
i += 1
if i >= n:
break
obj, i = _dec.raw_decode(s, i)
yield obj
def fetch(service, start, end, text, limit=1000):
r = subprocess.run(
["render", "logs", "-r", service, "--start", start, "--end", end,
"--text", text, "--limit", str(limit), "-o", "json", "--confirm"],
capture_output=True, text=True, check=True)
recs = list(stream_objects(r.stdout))
if len(recs) >= limit: # RECORDS, not lines
raise RuntimeError(f"capped at {limit}; narrow the window")
return recs
def payload(rec): # app payload nested in "message"
try:
return json.loads(rec["message"])
except (KeyError, json.JSONDecodeError):
return Noneraw_decode is the right primitive because it returns the index where the object ended, so it handles concatenated objects with arbitrary internal whitespace and needs no delimiter guessing.
jq handles this shape natively — jq -s slurps concatenated objects into an array — so render logs ... -o json | jq -s 'length' is a correct one-liner count. | wc -l is not.
Instance labels exist only in -o json; the -o text stream merges instances with no label, so any per-instance analysis is forced onto this JSON path and therefore onto this parser.