A correlated trail you can grep. Zero dependencies, two halves, a Go module and an npm package, that stitch a user's actions to the requests they caused.
19:15:16.030 INFO trace=01234567 user=- ses=9f21aa ├─ ui.press id=btn-signin label="Sign in"
19:15:16.088 INFO trace=01234567 user=- ses=9f21aa ├─ auth.login started
19:15:16.089 DEBUG trace=01234567 user=- ses=9f21aa │ ├─ store.user_lookup ok user_id=1 matched_by=username
19:15:16.171 DEBUG trace=01234567 user=- ses=9f21aa │ ├─ bcrypt.verify ok user_id=1
19:15:16.178 DEBUG trace=01234567 user=- ses=9f21aa │ ├─ session.create ok ttl=12h0m0s ref=72254378
19:15:16.178 INFO trace=01234567 user=1 ses=9f21aa ├─ auth.login AUTH_OK took=90.7ms
19:15:16.182 INFO trace=01234567 user=1 ses=9f21aa POST /api/v1/auth/login AUTH_OK status=200 took=94.3ms
One grep, one trace id, the whole story, including the half that happened in the browser.
When someone reports a problem they will not remember what they did. The trail
has to remember for them. That means specific outcome codes
(AUTH_REJECTED_BAD_PASSWORD, not "login failed"), an actor on every record,
and correlation that survives the browser/server boundary.
Coverage is structural, not remembered. You wrap the router once and every route, including ones added later by someone who never read this, is instrumented. You attach one listener and every control is captured. A build-time checker fails CI on anything that escaped. Nothing depends on anybody remembering to add a log line.
go get github.com/griffinwebnet/sh-trail@v0.1.0
npm install github:griffinwebnet/sh-trail#v0.1.0tr, err := trail.Init(trail.Options{
Service: "my-api",
File: "/var/log/myapp/api.log", // grep this
})
defer tr.Close()
mux := http.NewServeMux()
mux.HandleFunc("GET /things", h.List) // untouched
mux.HandleFunc("POST /things", h.Create) // untouched
srv := &http.Server{Handler: httpmw.Wrap(mux)} // all of it instrumentedThat is the whole baseline. Add detail only where it pays:
trail.Step(ctx, "store.lookup ok", "rows", n)
trail.Outcome(ctx, "THING_REJECTED_QUOTA_EXCEEDED") // lands on the request recordDEBUG=true mirrors everything to stdout as well, same records, same
encoding, one extra destination. trail.SetDebug(true) does it at runtime.
import { trail } from 'sh-trail'
trail.init({ endpoint: '/api/v1/events', app: 'my-console' })Captures every click and submit by data-trail, id, aria-label or visible
text; wraps fetch so each request carries traceparent and is attributed to
the interaction that caused it. No component knows about any of it.
Watch a user's actions live while they reproduce a problem, no redeploy, no reload:
__trail.debug(true) // in DevToolsshtrail-check --go ./api --js ./web/src| Rule | Meaning |
|---|---|
UNINITIALIZED |
no trail.Init / trail.init() reachable |
UNWRAPPED_ROUTE |
an http.Server or ListenAndServe whose handler misses httpmw.Wrap |
UNWRAPPED_ROOT |
createRoot() with no sh-trail wiring |
ANONYMOUS_CONTROL |
interactive element with nothing to name it in the trail |
RAW_TRANSPORT |
XMLHttpRequest / sendBeacon, invisible to the fetch wrapper |
Exit 1 on any finding. Exemptions go in .shtrailignore and each needs a
reason, an unexplained exemption is itself reported.
Your audit table and your outcome vocabulary. Those are application
decisions. Implement trail.Sink, filter the records worth keeping, write your
own schema:
type auditSink struct{ db *sql.DB }
func (s auditSink) Write(r trail.Record) error {
if r.Outcome == "" { return nil } // operational detail, not audit
_, err := s.db.Exec(`INSERT INTO audit_events (...) VALUES (...)`,
r.Time, r.Trace, r.Actor.ID, r.Name, r.Outcome)
return err
}
func (s auditSink) Close() error { return nil }
trail.Init(trail.Options{Sinks: []trail.Sink{auditSink{db}}})- Secrets are logged as references, never values.
trail.Ref(token)→ref=72254378. Never log a password, a raw token or anAuthorizationheader. - One record is one line. Values containing whitespace, quotes or control
characters are quoted, so attacker-controlled input cannot forge a record.
An inbound
traceparentis validated for the same reason. - Telemetry never breaks the app. The browser half swallows every failure, buffers with a cap, and reports what it dropped.
- Zero dependencies, both halves. Hand-written
.d.ts, so TypeScript consumers get types with no build step.
Ids are W3C traceparent from the outset, so adopting OpenTelemetry later is
an exporter, not a rewrite of every call site. The log shows the first 8 hex
characters, short enough to read off a screenshot, and a strict prefix of the
full id, so grepping either works.
- docs/go.md, Go integration
- docs/browser.md, browser integration
- docs/checker.md, coverage checking and CI
MIT. Copyright (c) 2026 Stinger Networks, Inc. See LICENSE.
Use it in anything, personal or commercial. Rename it, fork it, gut it, ship it inside something you sell. The one condition MIT imposes is that the copyright notice travels with it, and keeping the LICENSE file in your fork covers that on its own. Nothing else is required of you.
One request, and it is only a request: if your software has an open source attributions screen, or a third-party licences page, or a credits file, list the audit trailing component there with a link back to this repo. Costs you a line, and it is how anyone else with the same logging problem ever finds out this exists.
Pull requests welcome. If something extends this usefully without breaking the workflow it was built for, it'll get merged.