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.
Measured on dev, 25 September 2026
Section titled “Measured on dev, 25 September 2026”| Sent | Returned |
|---|---|
| curl, 354-byte log, no headers | 200 COMPLETED, 3 findings |
same + Prefer: respond-async | 202 RUNNING, batchId, files[0] QUEUED |
| 10 screenshots | 200 COMPLETED, 20 findings, 31.3s |
| 11 screenshots | 202 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.
Who is calling
Section titled “Who is calling”The endpoint reads the caller’s User-Agent and Sec-Fetch-* headers and
answers a person on the connection:
| caller | default |
|---|---|
| Browser, Postman, Insomnia, Thunder Client, HTTPie | synchronous — the report, immediately |
| curl, wget, python-requests, axios, okhttp, Go | asynchronous — 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.
What changed on 9 September 2026
Section titled “What changed on 9 September 2026”Nothing here is breaking. Every default is unchanged, so an existing integration behaves exactly as it did.
| Change | Affects | Detail |
|---|---|---|
narrow=false | API response | The question is read and reported, never applied. view.asked carries what it named |
view.asked | API response | New field alongside view.severities; [] on a response that predates it |
info in a question | API response | info, 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 happening | UI | description / recommendation / detectedBy lead the report instead of only sitting inside each finding row |
| You asked about … | UI | The named severity leads; when nothing matches, one line says so |
| The opening line | UI | One unconditioned sentence per turn: what was read, over what window, whether anything failed |
| The merged block | UI | Concludes instead of repeating — folded findings, no restated causes or evidence |
| One row of questions | UI | Chat’s and the analysis’s merge to a single row capped at four |
| One window format | UI | The opening line and the Window fact now match summary.headline |
| Prose, not a card | UI | The whole report is markdown in the answer itself. 1,541 words on a two-index turn, against ~490 |
| The heading names the source | UI | A readable name derived from the handle, with the handle kept beside it |
| The AI pass runs on a healthy file | API response | See below — the correlation pass was silently skipped on any clean file that had one incidental detector match |
| Auto shaping | UI | shape / summary / depth are no longer sent unless a reader moves them, so the question decides |
| Nothing pre-selected | UI | A guessed project no longer arrives as a filled-in field, and no index is seeded from its binding |
| History is sortable | UI | Analytics → 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 rows | model findings | |
|---|---|---|
| before | 1 | 0 |
| after | 6 | 2, 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 said | Category it was given |
|---|---|
| Resource owner password validation is active and succeeding | BRUTE_FORCE |
| Logout flow is the dominant recurring operation | BRUTE_FORCE |
| Cache warming is the dominant operation | LATENCY_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
recommendationfield is never searched, and never was. Onlytitleanddescriptionfeed 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.
The multi-index retrieval fix
Section titled “The multi-index retrieval fix”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 entries1,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:
| Query | Before | After |
|---|---|---|
severity=SEVERE, both indexes | total_hits: 1351, rows are DEBUG | 0 |
severity=SEVERE, either index alone | 0 | 0 |
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)”| Change | API response | UI |
|---|---|---|
A synchronous POST to /{workspace} now returns the report, not the batch envelope | batchId, 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.
Additive — new fields and a new route
Section titled “Additive — new fields and a new route”| Change | API response | UI |
|---|---|---|
POST /{workspace}/analyze | new route. Always synchronous, capped at 16MB, returns the report. Over the cap it answers 413 naming the batch route rather than dying on a socket | none |
summary gains the point-to-point form | headline, 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 shape | none yet |
topPatterns[].example | a real verbatim line from the file beside the masked label | none yet |
| Upload origin on the dataset | _meta.logrite = { origin, client, workspaceId, createdAt, updatedAt, sources[] } written to the index after a successful store | new “Origin” column on Analytics → Add Data → Index History: API, Web, Cortex, or System, with the calling product beside it (API · Postman) |
Per-event ingestClient | stored alongside ingestChannel on each document | visible in Discover as a field |
405 on a wrong method | GET 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 HTML | none |
Removed — redundancy only, no information lost
Section titled “Removed — redundancy only, no information lost”| Removed from the report | Where the same fact still is |
|---|---|
limits (2,012 bytes) on success | GET /{workspace}/limits, and still attached to every rejection |
metrics per finding | totalEvents = summary.eventsAnalyzed; matchedEvents = the finding’s events; ratio = those two divided |
threatsDetected, securityFindings, complianceFindings, observabilityFindings | summary.findings.byType — kept on the batch shape, dropped only from the report |
| duplicate evidence | the 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.
| Change | API response | UI |
|---|---|---|
| A stack trace is folded into the entry that threw it | eventsParsed, 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 upload | document 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 level | events that were previously stored undated and levelless become findable by time window and severity filter | rows that existed but matched no filter now appear in Discover, dashboards and Cortex answers |
exception detector minCount 3 → 1 | a 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 three | more findings on small files |
| A caller identified as a browser or an API console is answered synchronously | Postman and a browser get 200 with findings instead of 202 with none. Prefer: respond-sync / respond-async and `?wait=true | false` always override this, in both directions |
| Recurring shapes quote a real line | Most 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 failure | the 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.
| Change | API response | UI |
|---|---|---|
summary.activity | new 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 file | was always empty whenever a file had no errors; now falls back to the most repeated shapes regardless of level, each carrying errorLevel: false | none yet |
| INFO findings on a working file | findings 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 fires | an 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 finding | the same prose wherever the overview is rendered |
| ⚠ Corrected 2026-09-24 | that 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 files | the 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 all | none |
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.
# 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:
| Was | Now | |
|---|---|---|
| Evidence rows | an 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 pack | a non-log admits its distinct lines, so there is always something to read |
| The prompt | opened "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 pass | second pass | |
|---|---|---|
| Files with no severity section at all | 25 | 0 |
| Files carrying request outcomes | 221 | 230 |
| Request outcomes counted | 1,737 | 2,184 |
Files with topPatterns: [] | 38 | 38 — 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.
The size ceilings, proven end to end
Section titled “The size ceilings, proven end to end”2 GB is the per-request ceiling, and it is one of three numbers:
| Bound | Value | Where it applies |
|---|---|---|
| Bytes per request | 2 GB | POST /{workspace} |
| Bytes per file | 256 MB | both routes |
Bytes on /analyze | 16 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:
| Upload | Events | Server time | Wall time from here |
|---|---|---|---|
| 1.3 MB | 13,725 | 6.5 s | 8.4 s |
| 10 MB | 107,108 | 34–52 s | 46–90 s |
| 15.9 MB | 128,797 | 52–54 s | 97–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.
A small file is not a fast call
Section titled “A small file is not a fast call”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:
| File | Bytes | Events | Server time |
|---|---|---|---|
AuthenticationService_139.log | 11,939 | 122 | 7,018 ms |
AuthenticationService_138.log | 22,542 | 187 | 10,159 ms |
AuthenticationService_140.log | 35,325 | 330 | 5,852 ms |
AuthenticationService_136.log | 46,195 | 470 | 5,786 ms |
AuthenticationService_132.log | 51,371 | 513 | 7,807 ms |
AuthenticationService_135.log | 59,055 | 610 | 5,751 ms |
AuthenticationService_133.log | 84,596 | 838 | 5,863 ms |
AuthenticationService_137.log | 100,148 | 1,038 | 5,661 ms |
AuthenticationService_134.log | 124,571 | 1,247 | 5,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 out | What happens | What you still get |
|---|---|---|
| during retention | the write stops between chunks | every finding, the full summary, and a warning naming how many events were stored and how many were not |
| before the correlation pass | it is skipped rather than started | the 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.
| Dropped | Where the same fact still is |
|---|---|
summary.headline | the 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.durationMs | how long the server took; not a fact about the log |
summary.errorRate | errors ÷ eventsAnalyzed, both of which are kept |
findings.byType | every finding carries its own type |
topPatterns[].example | label is the same line, masked; errorLevel carries the rest |
per-finding id, detectedBy | provenance, 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 | |
|---|---|
| full | 279,234 |
| lean | 195,184 |
| saved | 30% overall — median 33% per file, best 51%, and larger on 0 of 29 |
Getting the full shape back
Section titled “Getting the full shape back”Three levels, and the first two need no deploy:
# per callcurl -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 pipecurl -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.
Nothing changed for
Section titled “Nothing changed for”POST /api/v1/log-analyses (the keyed route), both GET reads, the async
acknowledgement, dataset naming, idempotency, the rate limits, and the
concurrency ceiling.
Still not done
Section titled “Still not done”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.
A rate needs more than one event
Section titled “A rate needs more than one event”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.
Two defects the parse fix uncovered
Section titled “Two defects the parse fix uncovered”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/opensearchwas 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.
What UNKNOWN levels mean on a screenshot
Section titled “What UNKNOWN levels mean on a screenshot”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.
Numbers a finding may quote
Section titled “Numbers a finding may quote”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.
How much of a picture is read
Section titled “How much of a picture is read”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.
Why a report raised nothing
Section titled “Why a report raised nothing”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.
Every file in a small batch is named
Section titled “Every file in a small batch is named”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.
| files | opening names |
|---|---|
| 1 | Cryptography.log |
| 2–3 | a.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:
categorystays constant, deliberately. It is what machines key on, and it is the reason a derived title is safe to introduce at all. Alert oncategory, 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.