@sticklightapp/sandbox-logger
v0.0.23
Published
The log record shape every sandbox type emits, plus the emitters for the React/Vite sandbox.
Readme
@sticklightapp/sandbox-logger
The log record shape every sandbox type emits, plus the emitters for the React/Vite sandbox.
Why
Sandboxes used to write logs like this — valid JSON, but every field worth querying concatenated into one string:
{ "level": "error", "timestamp": 1785655783509, "pid": 132, "hostname": "f3022ba4-…",
"message": "7:29:43 AM [vite] Internal server error: `@custom-variant` cannot be nested.\n Plugin: @tailwindcss/vite:generate:serve\n File: /home/sandbox/src/index.css\n at …" }You cannot filter by file, group by error type, or count distinct failures. Now:
{ "ts": 1785655783509, "level": "error", "source": "devserver",
"errorType": "Internal server error",
"message": "`@custom-variant` cannot be nested.",
"plugin": "@tailwindcss/vite:generate:serve",
"file": "src/index.css",
"stack": "at …" }The contract
A sandbox type is conformant if every line of /tmp/dev.log parses as JSON against
SandboxLogRecord. That is the whole contract. A new sandbox type — different framework,
different runtime, not even Node — writes its own emitter and stays compatible, because consumers
read the schema rather than any particular emitter.
See src/schema.ts for the field list. Three fields carry the weight:
source—devserver|browser|command|build. Lets all streams in a sandbox be read as one timeline.event— stable machine name for lifecycle records (build-error,build-success,hmr-update,page-reload). Consumers match on these, so treat them as API.raw— set only when parsing was partial. Nothing is ever dropped, and parser gaps are findable by querying for its presence.
Paths are always repo-relative (src/pages/Index.tsx), never absolute sandbox paths.
Getting fields, not strings
Vite hands logger.error(msg, { error }) a RollupError carrying plugin, id, loc and
frame as real values, then formats them into a human-readable string. Read them off the error and
nothing is lost; parse the formatted string afterwards and you are guessing.
createViteLogger takes the first path. parseViteMessage is the fallback for messages that never
reach vite's logger — anything a plugin or dependency writes straight to console.error.
Its fixtures are verbatim lines from /tmp/dev.log on running production sandboxes. Keep them that
way: a hand-written fixture stops the test proving we can parse what vite actually emits.
Usage
import { createSandboxLogger, createViteLogger, writeRecord } from '@sticklightapp/sandbox-logger';
const sink = createSandboxLogger();
const emit = (record) => writeRecord(sink, record);
export default defineConfig({ customLogger: createViteLogger(createLogger(), emit) });Adding a sandbox type
- Emit
SandboxLogRecordNDJSON to/tmp/dev.log, one record per line. - Set
sourcecorrectly and reuse the existingeventnames where the concept matches. - If the runtime formats errors before you see them, write a parser like
parseViteMessageand test it against real captured output — not invented samples.
Publishing - required before the template can use it
The template depends on this package by exact version ("@sticklightapp/sandbox-logger": "0.0.9"),
and the snapshot image runs a bare npm install inside the sandbox (libs/snapshot-tools/src/lib/build-snapshot-image.ts)
with no .npmrc and no token. It can only reach the public npm registry, so this package must be
published there. Unpublished, that install fails and no snapshot can be built.
Three things make that work, and all are easy to get wrong:
- It lives in
packages/, notlibs/..github/workflows/deploy-package.yamldecides whether to publish by looking forpackages/<name>/project.jsoncarrying adeploy:npmtag. A package underlibs/setsshould_deploy=falseand is skipped silently - no error, no publish. - The nx project name in
project.jsonmust be the bare directory name, not the scoped package name. The release tag is{projectName}/{version}, and the publish gate derives the directory withcut -d'/' -f1. Name the project@sticklightapp/sandbox-loggerand the tag becomes@sticklightapp/sandbox-logger/<version>, so the gate looks forpackages/@sticklightapp/and skips - silently again.package.jsonkeeps the scoped name; onlyproject.jsonis bare, same assandbox-internal-sdkandsticklight-cli. publishConfig.registrymust be npmjs. The same workflow routes anything else to GitHub Packages, which is private and unreachable from a sandbox.dist/must be a valid package root. The publish job runspnpm versionandnpm publishinsideDIST_DIRECTORY, sodist/package.jsonhas to exist with dist-relative paths -tscalone does not produce one, and the job dies onCannot find module './package.json'. That is whatscripts/prepare-package-dist.mjsis for, mirroringsandbox-internal-sdk. Notefilesthere cannot be["dist"]as it is in the repo-root manifest, or the tarball would be empty.
Rollout - three ordered steps
All three steps are done as of 2026-09-14 - 0.0.9 on npm, baked into
supabase-react-template:1.12.0, and pinned. Keep the order if you ever redo them.
nx releasefor this project -> GitHub releasesandbox-logger/<version>-> Deploy Package publishes it.release.yamlonly releases affected projects, and itsnx releasestep pushes to master, so a concurrent release can lose the race and drop the tag. If nosandbox-logger/<version>release appears, re-run the release on master. Do not reach for_Manual Packageto get a version the template can pin. It publishes<latest-tag>-<branch>-<sha>(microservice-pre-job.yaml), so you get a branch-named prerelease like0.0.7-my-branch-6e25132- fine for unblocking or testing, wrong to bake into a production image. Only the release path produces a clean semver. Verify it landed:curl -s -o /dev/null -w "%{http_code}" https://registry.npmjs.org/@sticklightapp%2Fsandbox-loggershould print200. Query the registry directly: a local.npmrcscoping to GitHub Packages makesnpm viewreport a false 404.pnpm nx run snapshot-tools:create-snapshot- runsnpm installin the image, which is why step 1 comes first.- Bump
DEFAULT_DAYTONA_TEMPLATEinapps/api-platform/src/core/services/sandbox/provider/daytona/daytona-sandbox-provider.tsto the new snapshot version. It is pinned, so new sandboxes keep the old image until it is raised.
Note vite.config.ts imports this package at config load time, so Node - not vite - resolves it.
The resolve.alias block does not apply to the config file's own imports, only to app code. An alias
cannot substitute for publishing.
Rollout note
Template changes only reach new sandboxes, via a new snapshot. Existing sandboxes keep the
vite.config.ts they were created with, so both record shapes will coexist in the wild until they
cycle. Consumers must tolerate a record with no source field.
Status
As of 2026-09-16 structured logging is on for new sandboxes: the template pins 0.0.12,
supabase-react-template:1.13.5 bakes it, and DEFAULT_DAYTONA_TEMPLATE points there. AI Lab pins
its own image (SNAPSHOT_NAME in api-ai-lab) and is still on 1.10.8, so it emits the legacy
shape.
Two things this rollout learned the hard way. The pin here and the published version drift silently:
1.13.0 through 1.13.4 were all baked while the template still pinned 0.0.9, so they shipped
without the field fix in 0.0.12 and nothing in the bake path complained. And versions above the one
we pin can already exist in Daytona unpinned, so check the registry before you pick a number -
create-snapshot's patch bump is safe (real semver), but check-snapshot's "(latest)" label is
not: it sorts by string, so it calls 1.9.3 newer than 1.13.4.
Never pin a version at or below 0.0.7. All of them, plus 0.0.8, write {,"ts":...} on every
line - pino's envelope separator with an empty envelope - which fails JSON.parse.
toSandboxLogRecords skips lines it cannot parse, so a sandbox on those versions reports no
devserver records at all, silently, with nothing in any error log to explain it. Every check short
of booting a sandbox and reading /tmp/dev.log passes, check-snapshot included, since it only
inspects baked files.
So when you bake, verify by booting the image, starting the dev server and confirming every line parses - not just that the file has content. Break a file and request it over HTTP too: vite only transforms a module when something asks for it, so the error path stays silent otherwise.
One thing to know about that path if you change buildRecord: vite hands customLogger.error both
a RollupError and its own formatted message, and neither is complete on its own. The error object
has real loc and frame for plugin errors but nothing at all for swc, which throws a plain
Error with its position inside the message text. The message carries vite's Internal server
error / Pre-transform error label, which the error object has no equivalent for. So the record
reads the error first and backfills from the parsed message - reading only one of the two silently
empties fields, which is exactly how errorType, line, col and frame went missing before.
Note the message has no 7:33:40 AM [vite] prefix on this path: vite's own logger adds that only
when it writes to the console. parseViteMessage therefore treats the prefix as optional.
The template imports this package in vite.config.ts at config load time, so if it misbehaves the
dev server does not start at all.
