The backend container runs three JVMs — Saxon 12, Saxon 9.6 and Xalan — whose
-Xmx values add up to 512 MB, inside a container limited to 256Mi. The limit was
half the heap the JVMs believed they had, so the kernel was killing the
container: OOMKilled twice a day on the 16th and 17th of August, five times a
day on the 18th and 19th, and 81 users in 24 hours got "transform service
unavailable" instead of a result. Before each kill the serial collector spends
hours in continuous full GC, which is why three of the five pods sat pinned at
exactly 0.5 CPU — their limit — around the clock, and why XSLT 2.0's p95 was
5.5s against 0.7s for the other two engines.
Those limits were never in the chart to begin with: the backend deployment has
never rendered a resources block, values.resources.backend was declared and
unused, and the live numbers came from a manual kubectl patch that an ArgoCD
sync would have silently dropped. So the chart now owns them, with room for
what actually runs in there. The CPU request moves 10m -> 250m as well: against
real usage of 500m the HPA read utilisation in the thousands of percent and the
deployment sat permanently at maxReplicas, unable to signal anything. And the
backend gets minReplicas 2, for the reason the frontend already learnt.
The other half is the mid-keystroke problem again, one level down. Gating the
automatic run on well-formedness took the error rate from 54% to 24%, but a
stylesheet whose XPath is half-typed is perfectly good XML, so it still went to
the backend and still came back as a compile error. A day of logs is mostly
that, keystroke by keystroke: 'current()/..[@N' then '[@Na' then '[@Name', and
"Required attribute 'select' is missing" 65 times on a single line. So the gate
now also declines to send an expression that is provably unfinished — a
required attribute that is absent, an empty value, a quote or bracket still
open, a trailing token that cannot end an expression. Facts about the text, not
guesses at intent: there are twice as many tests for what it must leave alone
as for what it catches, including every template the app itself ships. It is
skipped above 64KB, where a document was pasted rather than typed, and as
before it only holds back the automatic run — "Run it anyway" is always there.
Finally, the error classifier learns the rest of what was sitting in "other":
Xalan's "Syntax error in '<expr>'" is the stylesheet's XPath, not the input.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018HnUwBeXv6PCTEzRpFoMSU
GORM's migrator crashes with "insufficient arguments" when it introspects
this composite-primary-key table if the table already exists — which is
exactly what happened on the first production rollout (my local end-to-end
test had created the table moments earlier), crashlooping the backend until
a rollout undo restored service.
CREATE TABLE IF NOT EXISTS is idempotent and has no such moods. A failure
now degrades to fiddles-off like every other storage failure, instead of
taking /transform down with it. Verified against all three paths: existing
table, fresh database, and restart over a self-created table.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZntQUfw463NW4ftgSjWw5
From the August competitive analysis, the items that close real gaps:
- Saxon HE 12.5 -> 12.9 (the closest competitor already ships 12.9). The
trace/hotspot instrumentation is reflection-based, so it was verified
against the new jar: variables and execution counts come back intact.
- Tracing now works for XSLT 2.0. The trace infrastructure needs Saxon 10+
APIs and cannot load inside the Saxon 9 daemon, so traced 2.0 requests run
on Saxon 12 in backwards-compatible mode instead of silently returning an
empty trace — a real trace with a small, labeled semantic difference.
Untraced 2.0 runs still use Saxon 9. XSLT 1.0 now says outright that XSLTC
has no tracing hook rather than pretending.
- Saved fiddles: POST /fiddle stores the workspace under a 7-char id with an
append-only revision history; ?f=ID loads it (optionally &r=N). Available
whenever DATABASE_URL is configured — unlike /history this is
unauthenticated, because a fiddle is a thing you share with someone who has
no account. An unreachable database degrades to "fiddles off" instead of
crashlooping: the chart has shipped a placeholder databaseUrl for a long
time, and opening it eagerly would have taken production down.
- The home page now tells the truth about engines: 1.0 on XSLTC, 2.0 on
Saxon 9, 3.0 on Saxon 12.9 — three real processors was already the
architecture, it just was never marketed.
- Four posts aimed at where the demand data points: the FreeFormatter
shutdown audience, Chrome's XSLT removal (v158, Nov 2026), Peppol/EN-16931
Schematron validation and ISO 20022 camt transformations. Every example was
executed against the real backend and the published output matches.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZntQUfw463NW4ftgSjWw5
Three things the error review turned up.
Secure processing caps an XPath expression at 100 operators, so the JDK was
refusing perfectly valid stylesheets with JAXP0801002 — a limit this service
imposes, not a mistake by the author. The XSLT 1.0 daemon now raises the op,
group and total limits instead of switching secure processing off, which also
guards external entity access; a runaway expression is still bounded by the
10s transform timeout, and any -D on the command line still wins.
That error is now classified as "backend" rather than "stylesheet": it belongs
with the bug candidates, not with user error, or the dashboard hides it.
The remaining unclassified errors were mostly classifiable all along, and the
patterns come straight from the production logs: multiply-defined and
undefined variables, format-number picture strings and missing Java extension
methods are the author's stylesheet; a stray <?xml ...?> declaration is the
input document.
Finally, calls to Java extension methods now say so: the playground runs stock
Saxon HE, so a class from the author's own project or their ESB runtime will
never resolve, and the raw "Cannot find external method" gives no clue why.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZntQUfw463NW4ftgSjWw5
Static rules can only guess at what is slow; the trace knows. The listener now
also counts template, apply-templates, for-each, for-each-group, call-template
and function executions, and reports the top 25 with the trace, so "this
template ran 4,312 times" is a fact rather than a heuristic.
The counting is kept strictly apart from the variable reporting it sits next
to. That deque pairs enter/leave and stays balanced only because both handlers
filter on isVariable() identically: pushing non-variable frames onto it would
make a variable's leave pop someone else's frame and silently attribute the
wrong value in the UI. countHotspot touches its own map and nothing else, and
handleEnter/handleLeave are unchanged.
Two things this needed along the way:
- Saxon never calls TraceListener.close() before the daemon reads the trace
buffer, so relying on it dropped every hot spot. attachTraceListener now
returns a flush the daemon runs once the transform is done.
- getConstructType() is null on these objects in Saxon 12 — which is why
isVariable() already falls back to class names — so constructs are
recognised the same way.
Verified end to end against a real transform: the variables still come back
intact (6 entries with their values) and a template applied to five elements
is reported as five executions.
Note for later: only SaxonDaemon attaches the listener, so tracing — and now
profiling — is XSLT 3.0 only. That predates this change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZntQUfw463NW4ftgSjWw5
TestPickSourceXMLReturnsDefaultWhenNoXML still expected the <root/> fallback
that 2bd3e94 deliberately removed so Saxon can invoke xsl:initial-template.
The test had been failing ever since; update it to assert an empty source.
This makes the backend suite green again.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZntQUfw463NW4ftgSjWw5
Saxon phrases the missing-input error as "an initial template or an initial
function" (spaces), so the hyphenated initial-template check never matched and
it fell through to class="other" — it was the 6th most frequent error in the
Loki triage (11x) and reproduced in production after deploying the counter.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZntQUfw463NW4ftgSjWw5
Triage of transform_error logs (Loki) showed ~500 failures piling into
class="other" — all of them user errors from the XSLT 1.0 processor, which
classifyTransformError didn't recognise (it only knew Saxon phrasing).
Backend:
- classifyTransformError now recognises JAXP/Xalan (XSLT 1.0) messages:
XML well-formedness failures -> input_xml; undefined-function / misplaced-
element / illegal-attribute / type errors -> stylesheet. Adds containsAny
helper and a table-driven test covering Saxon and Xalan phrasings.
Frontend:
- detectVersionUpgradeHint(error, version): when a transform fails because a
newer-version function is called (e.g. current-date()/tokenize() in 1.0, or
map:/array: in 2.0), the error box shows "<fn>() is an XSLT 2.0 feature —
switch to XSLT 2.0" with a one-click button that bumps the tab version.
- Requires the call shape to fire, so plain words don't false-positive.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KZntQUfw463NW4ftgSjWw5
LCP was gated on the backend: the largest element on the page is a
CodeMirror line in the *result* pane, which could not exist until the
2s debounce plus the POST /transform round-trip had completed.
- Seed the welcome example's known Saxon output into the result pane on
first visit so it paints on mount. The real transform still runs and
overwrites it; duration/serverMs stay null so no timing is claimed
until the real run lands.
- Drop the static xml-formatter import that was silently defeating the
existing dynamic import (Vite warned about this).
- Load lint, autocompletion, hover docs and the ~50KB completions table
after mount via requestIdleCallback; the editor reconfigures when they
arrive and degrades gracefully if they fail.
Critical index chunk 100KB -> 56KB (28KB -> 17.7KB gzipped);
xsltCompletions split into its own deferred 39.7KB chunk.
Measured over 3 runs, mobile emulation, 4x CPU throttle, 1.6Mbps:
LCP 4064ms -> 2032ms median, CLS 0.033 -> 0.036, FCP unchanged.
Also includes the in-progress timing work: the success box now shows the
client round-trip alongside server-side Saxon time, and Saxon
compilation failures without a code prefix classify as "stylesheet".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JDk5guu51FjFxQMjgrrekW
Every failed transformation now emits a single JSON line to stdout
("event":"transform_error") that promtail already ships to Loki:
- class: input_xml (user data) / stylesheet (user XSLT) / backend (bug
candidates) — lets review focus on real defects
- error_code extracted from the Saxon message (XPST0017, FORX0002…)
- xslt_b64 / params_b64: full payloads (capped at 48KB/field)
- repro_url: share-format URL (?xslt=&xml=&version=) — one click opens the
playground with the exact failing case
- New "XSLT Playground — Errors" Grafana dashboard (errors by code, by
class, recent log lines) added to the chart's dashboard ConfigMap
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgybX3QbAWVzb9ZnroCA79
pickSourceXML always ran html.UnescapeString on the chosen source document,
turning valid "&" into a bare "&" and breaking XML parsing across all
engines (e.g. the default welcome example failed with "The entity name must
immediately follow the '&'"). The same corruption was already fixed for the
parameters path in e2fdbfd but the source path was missed.
Only unescape when the value is HTML-encoded (starts with "<"); leave
already well-formed XML untouched.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MgybX3QbAWVzb9ZnroCA79
Instrument the Go backend with Prometheus metrics (HTTP request rate/
latency/in-flight and per-version transformation outcomes, Saxon duration,
payload size, trace usage), exposed on a dedicated internal port 9100 so
they are never routed through the public ingress. Add an nginx exporter
sidecar to the frontend. Ship ServiceMonitors (release=kube-prom-stack)
and a Grafana dashboard ConfigMap via the Helm chart.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
When an XML parameter value starts with '<', html.UnescapeString was
incorrectly called, converting & → & and breaking well-formed
XML that contained entity references. Only apply HTML decoding when
the value starts with '<' (HTML-encoded XML sent from a form field).
Fixes: 'The entity name must immediately follow the & in entity reference'
error reported when XML input contains & characters escaped as &.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Saxon 9.6 lacks ErrorReporter/XmlProcessingError (introduced in Saxon 10),
so Saxon2Daemon is now self-contained using JAXP ErrorListener. Runner.java
is excluded from the Saxon 9.6 build step.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Saxon 9.8+ supports XSLT 3.0, so Saxon 10 had the same validation gap
as Saxon 12. Saxon 9.6 is the last release focused on XSLT 2.0; it does
not process xsl:mode or other XSLT 3.0 declarations, making it a proper
XSLT 2.0 validator. Downloaded from SourceForge at build time.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Saxon 9.9.x is not available on Maven Central; Saxon 10.9 is the closest
alternative (last pre-12 major release, stricter 2.0 semantics than 12).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Each XSLT version now runs against its own true processor rather than
everything going through Saxon 12 in backwards-compatible mode:
- version=1.0 → port 8082, JDK built-in XSLTC (proper XSLT 1.0 validator)
- version=2.0 → port 8083, Saxon HE 9.9.1.8 (true XSLT 2.0 semantics)
- version=3.0 → port 8081, Saxon HE 12.5 (unchanged)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Backend no longer defaults to <root/> as source document; when no XML
parameter is detected, source is empty and setInitialContextNode is
skipped, allowing Saxon to invoke xsl:initial-template automatically
- Remove "The only free online XSLT" wording from meta descriptions
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The shell was expanding /opt/saxon/* into space-separated filenames,
causing Java to treat each jar as a separate argument and fail with
ClassNotFoundException. Single-quoting lets the JVM handle the glob.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Start a long-lived Java HTTP server (SaxonDaemon) on 127.0.0.1:8081 at
container startup. The Go backend now proxies /transform calls to it instead
of spawning a new JVM per request, eliminating ~500ms cold-start overhead.
- SaxonDaemon.java: HttpServer on :8081, two Processor instances (normal +
trace), warm-up at startup, per-request compile/transform
- Runner.java: make enableCompileWithTracing, DeduplicatingErrorReporter and
attachTraceListener package-private for daemon access
- main.go: remove exec.Command path; POST to daemon, parse JSON response
- Dockerfile: compile SaxonDaemon, download Gson, ship start.sh
- start.sh: wait for daemon /health, then exec Go server
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Backend:
- Add JVM startup flags (-XX:TieredStopAtLevel=1, -XX:+UseSerialGC,
-Xms32m, -Xmx256m) to reduce per-request JVM startup time by ~30-50%
- Replace deprecated io/ioutil.TempDir with os.MkdirTemp (Go 1.17+)
Frontend:
- Track isRunning per workspace in workspaceStatus
- Show animated "Running…" indicator while transform is in-flight,
replacing the stale "Success in X ms" badge
- Blur and disable pointer events on the result editor while running
so users know old output is stale
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Frontend:
- Add XSLT 3.0 option to version selector
- Add tests: version selector renders 1.0/2.0/3.0, version change updates stylesheet
Backend:
- Validate version field: reject anything other than 1.0, 2.0, 3.0 or empty
- Add tests: pickSourceXML (preferred keys, fallback, no XML, HTML entity unescaping)
- Add tests: version validation rejects invalid, accepts all valid values
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>