Skip to content

Log Analysis API — change history

This page is the history of the Log Analysis API. Each section records a change as it was made, with the measurements taken at the time; names such as acme in these records are the dev demo workspace. For the current contract — authentication, routes, request, response, limits and errors — read the reference page.

The answer comes back by default (25 September 2026)

Section titled “The answer comes back by default (25 September 2026)”

What it used to do, and why partners read it as broken

Section titled “What it used to do, and why partners read it as broken”

The decision was caller.interactive && bytes <= 16MB. interactive is true only for a browser and the API consoles (Postman, Insomnia, Bruno, Thunder Client), so curl, axios, python-requests, okhttp, Go and every unrecognised user agent were batched at any size — the size ceiling existed and no real integration could ever reach it. They got:

HTTP 202
{ "status": "RUNNING", "files": [ { "status": "QUEUED" } ] }

There is no findings key in that body, so the endpoint read as producing nothing. A caller’s user agent is not a statement about how long their upload takes to analyse, and it no longer decides this.

SentReturned
curl, 354-byte log, no headers200 COMPLETED, 3 findings
same + Prefer: respond-async202 RUNNING, batchId, files[0] QUEUED
10 screenshots200 COMPLETED, 20 findings, 31.3s
11 screenshots202 RUNNING with a batchId, 0.7s

⚠ Suites that need a batch now ask for one. Several tests reached the batch path through the default and silently changed subject when it moved — the concurrency ceiling suite stopped having anything in flight to refuse, and would have read as broken while working. The case asserting the old default is inverted and mutation-checked: restoring caller.interactive turns four red.

The endpoint reads the caller’s User-Agent and Sec-Fetch-* headers and answers a person on the connection:

callerdefault
Browser, Postman, Insomnia, Thunder Client, HTTPiesynchronous — the report, immediately
curl, wget, python-requests, axios, okhttp, Goasynchronous — a batch id, as before

This only ever fills a silence. Prefer: respond-sync / respond-async and ?wait=true|false always win, in both directions. Above 16 MB an interactive caller stays asynchronous: holding a connection across a large analysis trades a confusing 202 for a proxy timeout, which loses the batch id as well as the findings.

A GET on the POST path used to return the web server’s own Cannot GET page. It now answers 405 with Allow: POST — an HTML page for a browser, the same JSON error envelope as every other refusal for everything else — naming the field name, ?wait=true, and /limits.

Nothing here is breaking. Every default is unchanged, so an existing integration behaves exactly as it did.

ChangeAffectsDetail
narrow=falseAPI responseThe question is read and reported, never applied. view.asked carries what it named
view.askedAPI responseNew field alongside view.severities; [] on a response that predates it
info in a questionAPI responseinfo, informational and note resolve to the info section. They resolved to low — so “show me the info findings” returned a different set of findings, presented as the ones asked for. The failure mode with no symptom
Why this is happeningUIdescription / recommendation / detectedBy lead the report instead of only sitting inside each finding row
You asked about …UIThe named severity leads; when nothing matches, one line says so
The opening lineUIOne unconditioned sentence per turn: what was read, over what window, whether anything failed
The merged blockUIConcludes instead of repeating — folded findings, no restated causes or evidence
One row of questionsUIChat’s and the analysis’s merge to a single row capped at four
One window formatUIThe opening line and the Window fact now match summary.headline
Prose, not a cardUIThe whole report is markdown in the answer itself. 1,541 words on a two-index turn, against ~490
The heading names the sourceUIA readable name derived from the handle, with the handle kept beside it
The AI pass runs on a healthy fileAPI responseSee below — the correlation pass was silently skipped on any clean file that had one incidental detector match
Auto shapingUIshape / summary / depth are no longer sent unless a reader moves them, so the question decides
Nothing pre-selectedUIA guessed project no longer arrives as a filled-in field, and no index is seeded from its binding
History is sortableUIAnalytics → Add data → History: every column sorts and filters; two headers were over the wrong cells

The model was never asked anything, on every healthy file with one stray match

Section titled “The model was never asked anything, on every healthy file with one stray match”

runModelPass returns before it touches the provider when the evidence pack has no rows. The happy-path fix that gives a clean file something to correlate over was guarded on the pack being literally empty — and that was too narrow.

Measured on a live customer index: 838 events, zero errors, and one line carrying an email address. That single SENSITIVE_DATA_ACCESS match made the pack non-empty, so the activity rows were skipped and the dominant operation the file repeats 154 times was never put in front of the model. It was shown a one-row table under a TASK line reading “return NOTHING rather than restating a single row” — and correctly answered {"findings":[]}.

The symptom, reported as “the core recommendation the LLM produces isn’t reaching Cortex, though the words arrive”: the counted prose was all present and every model-written recommendation was missing.

The condition is now what it always meant — nothing in this file FAILED. A file with errors is byte-identical, which is the regression guarantee. A/B on the deployed build, same index, same signals:

evidence rowsmodel findings
before10
after62, each with a recommendation

The prompt’s healthy-file paragraph also names its own exception now. Without that the model reads the single-row rule as still in force even when it has rows, and it is asked for a recommendation that is worth acting on when nothing is broken — what to watch, what this baseline means, what would indicate it had changed.

Three category keywords read that new prose as a broken file. The mapping in modelCategories was written for error-shaped findings, and a description of ordinary work trips it: recurring alone is not a failure, a bare password is an auth mechanism rather than an exposure, and followed by is how anyone describes any sequence. A healthy logout flow was filed under RECURRING_FAILURE, CREDENTIAL_EXPOSURE and ATTACK_CHAIN — and ?category= is a published filter value, so each of those puts a clean file in a drawer marked with someone else’s problem. All three now require the failure to be in the words.

Then the advice itself became the category. Narrowing those three keywords fixed the words, not the mechanism. The prompt asks a healthy file for what to watch, and the model answers with the name of a threat — so toModelCategory, which reads the title and description together, took the warning for the finding’s subject. Measured on the deployed build against two live indexes:

What the finding saidCategory it was given
Resource owner password validation is active and succeedingBRUTE_FORCE
Logout flow is the dominant recurring operationBRUTE_FORCE
Cache warming is the dominant operationLATENCY_DEGRADATION

Every one is an INFO finding on a window in which nothing failed.

A category may only describe what a finding claims to have observed, so advisory sentences are removed before the keywords are read. A sentence is advice when it carries one of: watch for / watch out, monitor for, alert on, keep an eye, look for / look out, to detect, to catch, in case, could / may / might / would / can followed by indicate / signal / suggest / mean / point to / imply / reveal / be, or if this / if that / if the.

Three consequences worth knowing if you consume category:

  • The recommendation field is never searched, and never was. Only title and description feed the mapping.
  • The title is joined as its own sentence, so a finding whose description is nothing but advice is still categorised on what its title observed.
  • Where every sentence is advisory — including the title, which the model does occasionally write that way — the opening clause is kept rather than dropping the finding to CORRELATED_ACTIVITY.

A finding that reports a real failure is untouched: Repeated failed login attempts from a single address. Monitor for account lockout as the count rises. is still BRUTE_FORCE, because the first sentence states it.

Not this endpoint, but it changes what a Cortex answer says about the same data, so it is recorded here.

The chat retrieval widens a search that returns nothing. Two of those fallbacks widened to match_all — which drops every filter, not just the time window. A severity-filtered search over two indexes that matched nothing therefore came back with every document in both, and the guard that exists to catch a false all-clear read that total and reported a false alarm instead:

1,351 error-level entries

1,351 was the total document count of the two indexes, which between them contain no errors at all. Measured on dev against authenticationservice_133-2905 and test_authenticationservice_132-2905:

QueryBeforeAfter
severity=SEVERE, both indexestotal_hits: 1351, rows are DEBUG0
severity=SEVERE, either index alone00

A widen may relax what bounds the search in time. It may not relax what the caller was looking for. The fallback now strips range clauses on time, timestamp and @timestamp and keeps everything else — so a text search still widens, which was the point of the ladder, and multi-index no longer answers a different question from single-index.

What changed on 3 September 2026, and what it affects

Section titled “What changed on 3 September 2026, and what it affects”

Every row below is deployed on dev and verified against the running build. “API response” is what a caller sees; “UI” is what changes in the Logrite application.

Breaking-ish — a default moved (one flag restores it)

Section titled “Breaking-ish — a default moved (one flag restores it)”
ChangeAPI responseUI
A synchronous POST to /{workspace} now returns the report, not the batch envelopebatchId, files[], statusUrl, pollAfterMs are gone from that response. summary is the first key, then overview, then critical/high/medium/low/info. Findings move from files[0].findings to the severity section they belong to. Add ?shape=batch to get the old body byte-for-byte.none — the batch shape appears nowhere in the UI
statusUrl / fileUrl are absolute"/api/v1/log-analytics/acme/BATCH-…" → "https://dev-aiservice.logrite.ai/api/v1/log-analytics/acme/BATCH-…". A client that string-concatenated a host in front of these must stop. A client that followed them verbatim now works, which it could not before.none

The asynchronous default is untouched: a POST with no wait/Prefer from a non-interactive client still answers 202 with a batch id, and both GET reads return exactly what they always did.

ChangeAPI responseUI
POST /{workspace}/analyzenew route. Always synchronous, capped at 16MB, returns the report. Over the cap it answers 413 naming the batch route rather than dying on a socketnone
summary gains the point-to-point formheadline, highlights[], timeRange, errors, errorRate, findings.bySeverity, findings.byType, topIssues[], topPatterns[]. The original eventsAnalyzed, levels and the four per-type counters are unchanged on the batch shapenone yet
topPatterns[].examplea real verbatim line from the file beside the masked labelnone yet
Upload origin on the dataset_meta.logrite = { origin, client, workspaceId, createdAt, updatedAt, sources[] } written to the index after a successful storenew “Origin” column on Analytics → Add Data → Index History: API, Web, Cortex, or System, with the calling product beside it (API · Postman)
Per-event ingestClientstored alongside ingestChannel on each documentvisible in Discover as a field
405 on a wrong methodGET on a POST path returns 405 + Allow: POST with the field name, ?wait=true and the /limits url — a page for a browser, JSON for everything else. It used to be the web server’s own Cannot GET HTMLnone

Removed — redundancy only, no information lost

Section titled “Removed — redundancy only, no information lost”
Removed from the reportWhere the same fact still is
limits (2,012 bytes) on successGET /{workspace}/limits, and still attached to every rejection
metrics per findingtotalEvents = summary.eventsAnalyzed; matchedEvents = the finding’s events; ratio = those two divided
threatsDetected, securityFindings, complianceFindings, observabilityFindingssummary.findings.byType — kept on the batch shape, dropped only from the report
duplicate evidencethe finding that first printed those lines; the duplicate carries evidenceSameAs: "F-1001"

15.1 KB → 6.0 KB on the same file.

Behavioural — the numbers themselves change

Section titled “Behavioural — the numbers themselves change”

This is the one to read carefully, because existing dashboards will move.

ChangeAPI responseUI
A stack trace is folded into the entry that threw iteventsParsed, summary.eventsAnalyzed and dataset.eventsStored fall, and errorRate rises, for any log with multi-line entries. On the measured estate exception_*.log went 81,596 events / 1.9% errors → 1,526 entries / 98.9%. Both readings describe the same file; the new one counts log entries and the old one counted lines. A warnings[] entry states both numbers on every affected uploaddocument counts in Discover, Index History and any saved visualisation over an affected index will drop, because fewer documents are stored. Nothing is lost — a stack trace is now one document with its frames in the message instead of eighty documents with no timestamp and no level
Every event now carries a timestamp and a levelevents that were previously stored undated and levelless become findable by time window and severity filterrows that existed but matched no filter now appear in Discover, dashboards and Cortex answers
exception detector minCount 3 → 1a file containing a single unhandled exception now raises an UNHANDLED_EXCEPTION finding. Before folding, one crash was eleven-plus lines, so a floor of 3 was cleared by one crash; folded, it would have needed threemore findings on small files
A caller identified as a browser or an API console is answered synchronouslyPostman and a browser get 200 with findings instead of 202 with none. Prefer: respond-sync / respond-async and `?wait=truefalse` always override this, in both directions
Recurring shapes quote a real lineMost repeated failures: <ts> -<n>:<n> [DBG] … became Most repeated errors (e.g.): [ERR] Unhandled exception. … (x210). A [DBG] line is no longer listed as a failurethe same prose appears wherever the overview is rendered

A file with nothing wrong in it — second pass, 3 September

Section titled “A file with nothing wrong in it — second pass, 3 September”

Reported after the first pass shipped: “why is it not able to give the response or summary and findings for the 200 happy cases? It gives the proper response to the negative or 400, 500 cases which is good and works.”

The endpoint was built entirely around things going wrong. Three independent gates all keyed on a line being error-shaped, so a healthy file fell through every one of them: clustering admitted a line only if its level or its message said error/exception/failure, every detector matches a problem, and the evidence pack — which is built from detector hits plus error clusters — came out with zero rows. The model pass returns before it calls the provider on an empty pack, so on a healthy log the model was never asked anything at all, silently.

Measured on three real customer files: 0 findings, 0 patterns, 0 evidence rows each. GenDocApi.log records 205 HTTP outcomes — 192 OK, 9 not-found, 4 forbidden, with a latency on every one — and the report mentioned none of it.

ChangeAPI responseUI
summary.activitynew object, present only when the file records request outcomes or spans more than one hour: requests, statusMix (2xx/3xx/4xx/5xx), topStatus, topRoutes[] (route, count, failed), slowestMs, averageMs, busiest (hour + count). Omitted, never zero-filled — only two of the twelve services measured write HTTP outcomes at all, and requests: 0 on a log that is not a request log reads as “no traffic” rather than “not that kind of file”none yet — the field is available to render
summary.topPatterns[] on a healthy filewas always empty whenever a file had no errors; now falls back to the most repeated shapes regardless of level, each carrying errorLevel: falsenone yet
INFO findings on a working filefindings was [] and the report had no severity sections at all. Now REQUEST_OUTCOMES and DOMINANT_ACTIVITY appear in the info section. Both are suppressed the moment anything at or above MEDIUM firesan info section now renders where the report was previously sectionless
summary.headline and overview"No findings were raised." → "Nothing in this file needs attention." on an error-free file, and "Worst of 1 finding: INFO …" → "Nothing needs attention — 1 informational note: …". An INFO note is no longer framed as a worst findingthe same prose wherever the overview is rendered
⚠ Corrected 2026-09-24that affirmative wording was keyed on severity <= INFO, which is true of a real finding the model raises at INFO. See A clean bill of health is decided by category below—
The model runs on healthy filesthe correlation pass is now given activity rows when and only when the pack would otherwise be empty, so a file that already produces evidence is byte-identical. Its prompt gains an explicit instruction not to manufacture a problem: a healthy log described as an incident is worse than no finding at allnone

What did NOT change: any file that already produced evidence. The activity rows are added only to an otherwise-empty pack, the healthy findings are suppressed by any finding at MEDIUM or above, and the overview’s “most repeated errors” list falls back to ordinary activity only when there are no error clusters — so an error-shaped file takes none of these branches.

Cost: clustering every line rather than only the error-shaped ones is +412 ms on a 2,973 ms analysis of 107,340 events (+14%) — measured, proportional, and bounded by the same distinct-shape ceiling as before. Activity clustering has its own budget: sharing one would let ordinary shapes fill it and start dropping the error shapes, turning a feature for healthy files into a regression on the broken ones.

Terminal window
# A log where everything worked. Previously: counts and "No findings were raised."
curl -X POST "https://dev-aiservice.logrite.ai/api/v1/log-analytics/acme?wait=true" \
-F "file=@./GenDocApi.log" -F "dataset=gendoc-healthy"
{
"status": "COMPLETED",
"summary": {
"headline": "3,428 events over 2025-12-16 14:28 → 2026-08-11 19:48 UTC (238d 5h), 4 error-level (0.1%). No findings above informational — 1 informational note: Request outcomes for the analysed window.",
"highlights": [
"3,428 events analysed, 2025-12-16 14:28 → 2026-08-11 19:48 UTC (238d 5h).",
"Levels: INFO 3,375 · WARN 49 · ERROR 4.",
"4 error-level events (0.1%).",
"205 request outcomes — 192 succeeded (2xx), 13 client error (4xx).",
"Busiest route: /capturedocumentationservice/api/GenDoc/BPMNHorizontalDoc (57 requests).",
"Slowest recorded response 10.93s, average 1.01s.",
"Busiest hour: 2026-03-17 14:00Z (752 events)."
],
"activity": {
"requests": 205,
"statusMix": { "2xx": 192, "4xx": 13 },
"topStatus": 200,
"topRoutes": [
{ "route": "/capturedocumentationservice/api/GenDoc/BPMNHorizontalDoc", "count": 57, "failed": 0 }
],
"slowestMs": 10930.1,
"averageMs": 1010.4,
"busiest": { "hour": "2026-03-17T14:00Z", "count": 752 }
}
},
"overview": "…205 request outcomes — 192 succeeded (2xx), 13 client error (4xx). Busiest route: …",
"info": [ { "id": "F-1001", "severity": "INFO", "category": "REQUEST_OUTCOMES", "…": "…" } ]
}

A route is masked so it is a route and not one per id: /api/results/12345 and /api/results/98 are both /api/results/{id}. A status field is only believed in the 100–599 range, so a queue_status: 904 does not become a status class nothing defines. And a single time bucket is never called “busiest” — that states a comparison that was never made.

A clean bill of health is decided by category, not severity

Section titled “A clean bill of health is decided by category, not severity”

Corrected 2026-09-24. summary.headline and overview say "Nothing needs attention" only when every finding is one of the four notes the healthy rules raise — CLEAN_WINDOW, NOTHING_ACTIONABLE, REQUEST_OUTCOMES, DOMINANT_ACTIVITY. Any other finding, at any severity including INFO, replaces it with "No findings above informational".

Until this change both renderers asked only whether the worst severity was at or below INFO. Every note is INFO, so the test looked equivalent — and it is not: the model pass raises genuine findings at INFO too.

What that shipped, measured on a partner’s upload of a screenshot of an error dialog:

{
"summary": { "headline": "16 transcribed lines ... Nothing needs attention — 2 informational notes: Nothing in this window needs attention." },
"info": [
{ "id": "F-1001", "category": "CLEAN_WINDOW", "title": "Nothing in this window needs attention" },
{ "id": "F-1002", "category": "DEPENDENCY_FAILURE", "title": "Cloud Agent dependency failure observed" }
]
}

The transcription was correct — the picture was an error dialog. The report then contradicted itself in its own headline, and F-1001 won the severity tie-break, so the self-cancelling sentence is the one a caller quotes.

If you integrate against this: "Nothing needs attention" is now a reliable statement that the report holds no findings beyond those four notes. It was not before. Read findings[].category rather than inferring from severity — an INFO finding whose category is not one of those four is a real observation about the file.

An image or document is read for MEANING, not for severity prefixes

Section titled “An image or document is read for MEANING, not for severity prefixes”

Added 2026-09-24. A file that is not a log — a screenshot, a write-up, a page of markup — is now put in front of the model even when nothing in it repeats, and the report no longer states a health verdict over it.

Two things were wrong, and both only affected non-logs:

WasNow
Evidence rowsan activity row needed count >= 2. A screenshot is a handful of distinct lines, so every signature had count 1, no row was added, and the pack was empty — and the model pass returns before calling the provider on an empty packa non-log admits its distinct lines, so there is always something to read
The promptopened "You are analysing ONE log file", and because a document has no level tokens its error count is always 0, which gated the instruction “Do NOT manufacture a problem”names the file as a document, states that these lines carry no severity of their own, and says to read the words
overview (summaryLevel: brief)"Overall health: clean. Nothing in this file is at error level."omitted — the rule execNarrative already followed

A log is byte-identical. Every branch above is gated on the file not being a log: the count >= 2 rule still holds for logs, the log prompt is unchanged including its “do not manufacture a problem” instruction on a healthy file, and Overall health is still reported. That is the regression guarantee.

The picture’s own description is not evidence about your system

Section titled “The picture’s own description is not evidence about your system”

Added 2026-09-24. The vision pass is asked to transcribe an image and then, after the transcription, to describe any non-text meaning — a chart, a diagram, a highlighted row — in one short paragraph under VISUAL:. That paragraph was separated from the transcription only when the picture had no legible text at all. On every screenshot that did transcribe, it stayed attached and was handed to the log parser as if your system had emitted it.

It did not become a harmless extra line. VISUAL: opens no log entry, so the continuation folder welded it onto the last transcribed record — which on a screenshot of an error dialog is the record holding the payload.

Where the description went instead. It is reported in the file’s notice, which is shown to you and never parsed into records:

{
"notice": "… The picture was also described by the transcriber: \"A plain white background with black text. …\" That description is the vision model's account of what the image LOOKS like, not output the system emitted, so it is reported here and is not analysed as records."
}

It is dropped from the evidence rather than given its own row because on this path it describes the picture, not the system. A picture whose meaning genuinely is non-textual returns NO_TEXT_FOUND, and that path already promotes the description to being the text — so nothing is lost for a chart or a diagram.

A file with no VISUAL: marker is byte-identical — every log, every text upload. So is a description with no transcription above it: cutting that would leave empty text and the file would be refused NO_TEXT_EXTRACTED, reporting a picture as unreadable because it had been described.

The whole corpus, twice — 312 files, 532 MB, 12 folders

Section titled “The whole corpus, twice — 312 files, 532 MB, 12 folders”

Every file under the customer’s CertifyResultsViewer tree, subfolders included, through the deployed route. 311 answered 200; one answered a correct 415 (a .zip — this endpoint reads one log file at a time). The first pass exposed two gaps that no single-file test had, and the second pass, after fixing them, closed both:

first passsecond pass
Files with no severity section at all250
Files carrying request outcomes221230
Request outcomes counted1,7372,184
Files with topPatterns: []3838 — all 1–20 events, nothing repeats

The missing outcomes were 191 × 503 among them: the estate writes .NET HttpClient lines, not Kestrel, and only the End processing HTTP request after Xms - CODE line is counted. HttpClient logs each request twice, so matching both shapes would report double the traffic that happened.

2 GB is the per-request ceiling, and it is one of three numbers:

BoundValueWhere it applies
Bytes per request2 GBPOST /{workspace}
Bytes per file256 MBboth routes
Bytes on /analyze16 MB/analyze only — it answers on the connection

Measured with a real 20 MB upload: /analyze answered 413 TOO_LARGE_FOR_SYNC naming the batch route, and the same bytes to that route answered 202 and completed — 77,211 entries, 9 findings. No file in the measured 312 exceeds 16 MB (the largest is exactly 10 MB), so the sync cap never bound in practice.

How long a synchronous call takes, and the clock it now runs against

Section titled “How long a synchronous call takes, and the clock it now runs against”

Reported after the size work: /analyze with a 10 MB log “was not able to give a response”. The file is fine — it answers 200 with 107,108 events. The problem was time, and it is worth stating plainly because it decides how you configure your client.

Most of the wall clock is your upload, not the analysis. Measured across the nine 10 MB files in one folder, sent three at a time:

UploadEventsServer timeWall time from here
1.3 MB13,7256.5 s8.4 s
10 MB107,10834–52 s46–90 s
15.9 MB128,79752–54 s97–108 s

Server cost is about 0.34 ms per event on top of a couple of seconds of fixed overhead, and it is dominated by parsing and by retention — not by the model, which answers in roughly five seconds. Everything above that is transfer, so the wall time you see depends on your uplink: on a slower link the same 10 MB file takes longer here and not one millisecond longer on the server.

This distinction is the difference between the two timeouts that matter. The proxy in front of this service is waiting on the server half only. Your HTTP client is waiting on both.

Reported next: the same “not able to give a response”, this time on a 12 KB log. None of the size work above applies to a file that small — and the service answered it, on its own record, with 200 in 5,567 ms.

So every file in that folder was timed. The clock does not follow the file:

FileBytesEventsServer time
AuthenticationService_139.log11,9391227,018 ms
AuthenticationService_138.log22,54218710,159 ms
AuthenticationService_140.log35,3253305,852 ms
AuthenticationService_136.log46,1954705,786 ms
AuthenticationService_132.log51,3715137,807 ms
AuthenticationService_135.log59,0556105,751 ms
AuthenticationService_133.log84,5968385,863 ms
AuthenticationService_137.log100,1481,0385,661 ms
AuthenticationService_134.log124,5711,2475,674 ms

All nine answered 200. The 124 KB file with 1,247 events came back faster than every smaller file in the folder, and the slowest call of the set was the 22 KB one. What is being paid for is a fixed cost of roughly 5.5 to 10 seconds — one correlation round-trip to the model — and the log itself adds milliseconds until it is megabytes.

There is therefore no upload small enough to answer in under about five seconds, and any of them can take ten. A client configured to give up at 5 s abandons every call to this endpoint, and one set to 10 s drops them at random; either way it happens while the server is mid-write, and the symptom is indistinguishable from the endpoint being broken — nothing comes back, and the service records a perfectly healthy 200.

A connection the proxy thought it still had

Section titled “A connection the proxy thought it still had”

Found while investigating the above, and fixed. It is a separate defect, and its signature is worse than a slow call: the request never reaches the service at all, so there is no route, no log line and no error — the caller simply waits.

Traefik keeps its connections to this service pooled for 90 seconds. Node’s own default closes an idle connection after 5. For the 85 seconds in between, the proxy holds a socket the origin has already finished with. Proven directly against the running container: a second request written 6 s after the first received no response, because the server had closed the connection underneath it.

A GET survives this — Go replays idempotent requests onto a fresh connection. A POST carrying an uploaded file has no rewindable body, so it cannot be replayed, and it dies. That is why this endpoint looked flaky while /health looked perfect.

The service now holds idle connections for 120 s, comfortably outliving the pool in front of it, with headersTimeout kept above that so it cannot cut in first. HTTP_KEEP_ALIVE_TIMEOUT_MS overrides it.

What was genuinely broken. This service is fronted by a proxy that stops waiting for the origin after a bounded stretch of server time — re-measured on 24 September 2026 as a 524 at 128.1 s, with ~106 s passing on three consecutive runs, so the true threshold is somewhere between and has not been bisected. (The 100 s written here previously was Cloudflare’s documented proxy limit, not a number measured against this estate, which reaches the origin over a tunnel rather than through the orange-cloud proxy.) The /analyze size cap was sized against that ceiling for a request running alone, but three analyses are allowed to run at once and they share one event loop — so three large uploads each take roughly three times as long and cross the ceiling together. Observed: a request that ran 202 s and returned an empty body, with the analysis complete and unreachable. Separately, the correlation pass was allowed 60 s on top of a cap sized for 48 s; both numbers were justified against the same ceiling and their sum never was.

The fix is a budget, not a smaller cap. A synchronous request now carries a deadline. Nothing is cancelled mid-flight and no request is failed by it — instead the two costs that scale with the file stop buying more work and say so:

If the budget runs outWhat happensWhat you still get
during retentionthe write stops between chunksevery finding, the full summary, and a warning naming how many events were stored and how many were not
before the correlation passit is skipped rather than startedthe deterministic findings, which are complete, and a warning saying why

The findings and the summary always describe the whole file. Only later querying of the stored dataset is affected, and the warning says so and points at the batch route, which has no clock.

The response is read by a model, so it says each fact once

Section titled “The response is read by a model, so it says each fact once”

Reported after the corpus pass: “our response will go on the other agent so amount of token will increase the budget so we need to as low as we can but need to add the proper summary and findings on the response.”

The body is billed twice — once to produce, once for the downstream agent to read. POST /{workspace}/analyze now returns the lean shape by default.

The cut is not “drop the least important fields”. Measured on a real 107,108-event upload, one recurring error line was carried six times: in the overview prose, again as a highlights entry, again as topPatterns[0].label, again as topPatterns[0].example, and as three near-identical evidence samples on the finding about it. So the rule is say each fact once — nothing distinct is removed.

DroppedWhere the same fact still is
summary.headlinethe first sentence of overview, word for word
summary.highlights[]the same facts as overview, as a list
summary.topIssues[]the three worst findings, printed in full in their own severity sections
summary.files[]the name, size and dataset of what the caller just uploaded
summary.durationMshow long the server took; not a fact about the log
summary.errorRateerrors ÷ eventsAnalyzed, both of which are kept
findings.byTypeevery finding carries its own type
topPatterns[].examplelabel is the same line, masked; errorLevel carries the rest
per-finding id, detectedByprovenance, not content
evidence[1…]one line shows the shape; three is for a human triaging

summary goes from 14 keys to 8. Everything a caller cannot recompute stays: timeRange, eventsAnalyzed, levels, activity, errors, warnings, findings.total, findings.bySeverity, topPatterns[] — and every finding keeps its description, recommendation, events and bounds. Degradation warnings[] are never dropped.

evidenceSameAs is inverted rather than kept. At three samples, a pointer to the finding that printed them saves real bytes; at one line it costs more than the line, and it makes the reader hold an id and come back for it — the traversal a model pays for twice. The owner’s line is inlined instead, so every finding stands alone.

Measured across 29 files spanning all 12 folders, comparing one analysis rendered both ways — the model pass is non-deterministic, so two uploads of one file are not one analysis and cannot be compared by size:

bytes
full279,234
lean195,184
saved30% overall — median 33% per file, best 51%, and larger on 0 of 29

Three levels, and the first two need no deploy:

Terminal window
# per call
curl -X POST 'https://dev-aiservice.logrite.ai/api/v1/log-analytics/worksoft/analyze?verbosity=full' \
-F 'file=@./application.log'
# …or as a header, for a raw-body pipe
curl -X POST 'https://dev-aiservice.logrite.ai/api/v1/log-analytics/worksoft/analyze' \
-H 'Prefer: full' -F 'file=@./application.log'

Per deployment, set LOG_ANALYSIS_DEFAULT_VERBOSITY=full; it outranks the per-route default and puts every route back on the full shape. Unset — the default — each route keeps its own: full on the batch doors, concise on /analyze. ?verbosity=full returns the previous body byte for byte, because the lean shape is a projection of the full report and never a second analysis.

POST /api/v1/log-analyses (the keyed route), both GET reads, the async acknowledgement, dataset naming, idempotency, the rate limits, and the concurrency ceiling.

Findings are returned and retained for 24 hours, but nothing in Analytics, Dashboard or Cortex can query them — only the raw events are. Making them queryable needs a new index family registered in the AI service and in the frontend’s tenant filter, plus a descriptor and a tool; a name outside a known family lands data where no tool can read it. Tracked, not started.

Cortex answering over an API-uploaded dataset and its findings is unproven end to end and depends on the item above. Chat can already be pinned to an uploaded index and answer over the raw events; what it cannot do is read the findings, because they are not in an index any tool can reach.

summary.activity is returned by the API but not rendered anywhere in the UI yet. The data is there; the surface is not.

A screenshot’s rows are records (2026-09-24)

Section titled “A screenshot’s rows are records (2026-09-24)”

Reported from Postman: change the image, get much the same answer back. Six unrelated error screenshots were sent through POST /api/v1/log-analytics/{workspace} and every headline opened with the same sixty characters, diverging only at the tail. Three separate causes sat behind that, and one of them was the parser.

Continuation folding does not apply to a picture

Section titled “Continuation folding does not apply to a picture”

foldContinuations decides “this line continues the one above” from the absence of a header: nothing matched the entry-start patterns, so it must be a continuation. That is sound for a log file, where a header is the convention. It is false for a picture — a vision model emits one line per visual row, and almost no row of a screenshot looks like a log header.

The Postgres screenshot opens FATAL: sorry, too many clients already, which matches on the level word. Everything beneath it was folded in, so the HikariPool timeout, the pool statistics Total=50, Active=50, Idle=0, Waiting=18 and the PSQLException stopped being records. Four rows became one event. The disk-full screenshot did the same on Error: — four rows, two events, the second holding the df row and the flood-stage watermark inside one message.

Text extracted from an image now keeps one record per row. PDF, Word and Excel still fold, deliberately: a text layer wraps a long line across rows, which is the opposite problem, and folding is what repairs it.

ELEVATED_ERROR_RATE reports the proportion of a file that failed, and it was computing one over documents. With a screenshot parsing as a single event, one error was “100%” — HIGH, therefore the worst finding, therefore the headline. The connection-pool screenshot never mentioned the connection pool; it said “Elevated error rate across the analysed window”, which is the same sentence whatever you upload.

The gate is readsAsLog, not a threshold: a rate is a claim about traffic, which is the invariant this API already applies everywhere else to a document. For a log the finding behaves exactly as it always did; errors and errorRate stay on the summary for a document either way, so no count is lost.

The headline leads with what makes the file different

Section titled “The headline leads with what makes the file different”

A document’s opening clause is the same sentence for every document of its kind, so it cannot distinguish one screenshot from another, while a log’s opening already does from its own count, rate and span. The clause stays — a reader has to carry “not log events” into every other number on the page — and moves behind the finding.

Splitting the rows correctly made 08-disk-full come back with a less specific finding — “Resource exhaustion reported” where it had previously said “Storage full triggers index read-only”. The old answer was an accident: folding had welded four rows into one message, and that message happened to carry the whole story. Two defects had been hiding behind it.

A sample is the caller’s own line, so the vendor scrub must not touch it. The scrub keeps our strings storage-neutral — a rejected write coming back as index_not_found_exception is a tour of our stack. A sample is the opposite: a line out of the file you uploaded, quoted back as evidence. It was being scrubbed too, so

/dev/mapper/vg0-data 920G 920G 0 100% /var/lib/opensearch

was published, and handed to the model, as /var/lib/the log store. You run the thing; it is your directory. Secret redaction and the length cap still apply.

Fixing that at the point the sample is built was only half of it: the envelope scrub walks every string on the way out, and findings[].evidence[] is your file quoted back. So the model, finally seeing the real path, recovered “index set read-only due to disk watermark” — while the response still published the df row as /var/lib/the log store, three times. The exemption now covers evidence, samples, sample and sampleLines, and it is sticky: whatever nests under them is yours however deep.

A document’s rows were dropped before the model saw them. The evidence pack adds a file’s distinct lines only when nothing in it failed, and that test needs two counters empty at once — which never happens on a transcript, because the error count is read off the level a screenshot row does not carry while a signature is error-shaped by its text. The disk-full screenshot’s second row begins Error:, so it had zero error-level events and one error signature, and the branch was skipped: four rows in the file, two rows in the pack, and no flood stage, read-only, 920G or /var/lib/opensearch in the prompt. The model never saw the df row.

A document has no background activity — its lines are the evidence — so it always takes that branch now. A log that really did fail is untouched; its background stays background.

Most rows come back UNKNOWN, and that is correct rather than a gap: curl: (60) SSL certificate problem carries no severity token, so there is none to read. Inferring one from keywords would manufacture a level the source never had. Detection does not depend on it — the disk-full screenshot still raises CRITICAL from the words themselves.

An absent ERROR level is not a clean bill of health

Section titled “An absent ERROR level is not a clean bill of health”

Because a screenshot has no level token, the error count on a transcript is zero by construction — not a measurement that came back clean, but one that could never have come back otherwise. So a picture is never described as a clean window and never offered as “the healthy baseline for this source”. Where no pattern reaches a finding threshold the response says exactly that, and points you at the findings below rather than closing the file as healthy.

Measured on dev, 2026-09-24: a screenshot reading Cloud Agent Error: Unable to reach the Cloud Agent came back as “Nothing in this window needs attention. Use this window as the healthy baseline for this source.” Every clause was true of the level census and false of the picture — the caller had uploaded a failure and was told to treat it as their baseline for healthy. A screenshot of a failure usually carries the error as a message, not as a level, so the findings are where the answer is.

A finding’s counts and timestamps are attached from the evidence rows, never taken from the model’s prose — a magnitude the model writes itself is one it may have produced, and in a log file it is discarded for that reason.

On an image that inverts, because the magnitude is what was photographed and no count of ours can express it: there is no measurement that means “the certificate expired in 2026”. So the test is provenance rather than presence — a number may stay in the prose when it appears verbatim in the evidence that was read. That cannot admit a fabrication, since a fabricated number is by definition not in the evidence. Before this, an expired-certificate screenshot was dropped on the year in its own notAfter field and a Redis OOM command not allowed dialog on its client count, each leaving the file with no finding at all.

“Read” means the whole evidence table, not only the rows a finding cites. A transcribed row holds a single line, so the line that names a failure and the line carrying its magnitude are usually different rows: a certificate finding cites SSL certificate problem: certificate has expired while the year it quotes sits on the expire date: line above. Both were read. Narrowing this to the cited rows alone answered twelve images out of fourteen correctly and lost exactly those two, because it made the outcome depend on which row a finding happened to cite.

One exception holds in both directions: a number attached to a countable noun — 812 errors, 40 attempts, 12 occurrences — restates a count, and counting is the platform’s. Those are refused however plainly the digits appear in the file; the count to read is the finding’s own events field.

Every transcribed line becomes a row in the evidence table, up to 40 lines. That bound is separate from the one a log uses, and deliberately so: a log holds thousands of lines and its evidence rows are its five most repeated shapes, because frequency is what makes a shape interesting. In a transcript every line is distinct, so every count is one, and a “top five” is simply whichever five the ranking happened to keep.

Measured on a screenshot of a curl against an expired certificate: the transcript was seven lines, the evidence table was five, and the two that fell off were SSL certificate problem: certificate has expired and the curl: (60) line repeating it. The model answered {"findings":[]} and was right to — it had been shown a certificate with a start date and an expiry date and no statement that anything was wrong.

Worse than the silence: given those five rows, models do not merely stay quiet. Across five providers and three runs each, the truncated table produced no correct answer and several confident wrong ones — “certificate not yet valid”, “short-lived certificate”, “short validity window”. With all seven rows, every model and every run returned HIGH — TLS certificate expired.

If a document has more than 40 distinct lines, the response says so in warnings, naming how many lines there were. A larger silent cap would be the same defect further along.

A response carrying only an informational note has three possible causes, and from outside they look identical. The service log distinguishes them, because a report that says nothing needs attention is exactly the case where you cannot tell a healthy file from a lost answer:

  • model pass raised nothing: the model returned an empty findings array — the model read the evidence and reported no issue. The note is the answer.
  • model pass raised nothing: no JSON object could be read from the completion — the model answered and the answer was unreadable. The note is not the answer; the file has not been assessed by the model pass at all.
  • dropped N/M model findings: <reason> — the model raised findings and the grounding rules discarded them, each reason named (cites-no-known-row, incomplete, unquoted-magnitude). This is the one that used to be invisible.

Reasons are logged, never descriptions: a finding’s prose is derived from a caller’s file.

A batch’s prose names its files, and its titles are derived (2026-09-25)

Section titled “A batch’s prose names its files, and its titles are derived (2026-09-25)”

Reported against the Cortex report on a two-screenshot upload, in the reporter’s words: “it gives 2 different files data in same line or something”, and separately “Why it gives same line for diffeernt erors like ‘ELevated error’ this text I got multiple times … ensrue that it should give actual dynamic resposne rather than any fixed words”.

Four defects, and three of the four are reachable only with more than one file — buildReport uses a single analysis’s own summary and calls mergeFacts only for two or more, which is why one image always read correctly and the whole class survived.

“Nothing could be read from this file”, above findings quoting that file

Section titled ““Nothing could be read from this file”, above findings quoting that file”

mergeFacts splits a batch: eventsAnalyzed sums the files that read as logs and documentRecords sums the ones that do not (see A document is analysed, not counted as traffic). For an upload where nothing is a log — two screenshots — eventsAnalyzed is therefore 0 by construction, and the entire count sits on documentRecords. The narrative read only the former, so it opened:

01-java-npe.png and 1 more — Nothing could be read from this file.

directly above the two findings read out of those very images, one of which says “4 transcribed lines carry it”. Measured on the real path: merged eventsAnalyzed: 0 beside documentRecords: 7.

01-java-npe.png and 1 more named exactly one file and counted the rest, at any batch size — so on a two-file upload the second file was never named anywhere in the report. That is the worst case of the rule rather than an edge of it: the reader can see there is another file and cannot find out which one.

filesopening names
1Cryptography.log
2–3a.png and b.png, a.png, b.png and c.png
4+a.png, b.png, c.png and 4 more

Past three, the count is the honest summary — a list that silently stops is indistinguishable from a batch that had nothing more in it.

A per-file breakdown, so two files’ findings stop arriving as one paragraph

Section titled “A per-file breakdown, so two files’ findings stop arriving as one paragraph”

Everything below the opening is a projection of the merged finding list, ordered worst-first. That is the right order for “what do I fix” and it carries no trace of which file each finding came out of. On one log the distinction does not exist; on two images it is the first thing a reader wants.

By file:
1. 01-java-npe.png — 4 transcribed lines; 2 findings, worst HIGH: java.lang.NullPointerException.
2. 08-disk-full.png — 5 transcribed lines; 1 finding, worst MEDIUM: index set read-only due to disk watermark.
  • Omitted entirely for a single file, where it would restate the opening.
  • Each row uses that file’s own record noun — a screenshot beside a log file must not have its transcribed lines called events.
  • A row names the worst finding rather than counting silently, so it answers “which file is the problem” on its own.
  • Up to 20 rows, then … and N further files, not listed here.

A finding’s title is derived from the failure, not written into the rule

Section titled “A finding’s title is derived from the failure, not written into the rule”

"Elevated error rate across the analysed window" was a hardcoded string in errorRateFinding, and topFailureFinding had the same shape. Only the description varied — and the title is the field that travels furthest:

  • the summary headline quotes it (Worst of 1 finding: HIGH — …)
  • the narrative opens each paragraph with it
  • the action list anchors each numbered instruction to it
  • the UI’s findings list shows it alone

So two uploads that found two completely different failures printed the same sentence in the most prominent position, and the part that differed sat behind it. The title is now built from the dominant failure signature.

Two details that are easy to get wrong the other way:

  • category stays constant, deliberately. It is what machines key on, and it is the reason a derived title is safe to introduce at all. Alert on category, never on the prose.
  • The label is clipped at 96 characters, not 64. A logger prefix is routinely 30+ (c.l.gateway.PaymentController - ), and the clip backs up to the last separator — on a dotted namespace 64 cut … - java.lang…, dropping the one token the title exists to carry.