Skip to content
griffinwebnetPublic

About

Dependency Free Audit Trailing Extension for Go and React

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

sh-trail

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.

Why it exists

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.

Install

go get github.com/griffinwebnet/sh-trail@v0.1.0
npm install github:griffinwebnet/sh-trail#v0.1.0

Go: wrap the router

tr, 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 instrumented

That 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 record

DEBUG=true mirrors everything to stdout as well, same records, same encoding, one extra destination. trail.SetDebug(true) does it at runtime.

Browser: one listener

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 DevTools

Enforce coverage

shtrail-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.

What it does not own

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}}})

Guarantees

  • Secrets are logged as references, never values. trail.Ref(token) → ref=72254378. Never log a password, a raw token or an Authorization header.
  • One record is one line. Values containing whitespace, quotes or control characters are quoted, so attacker-controlled input cannot forge a record. An inbound traceparent is 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.

W3C Trace Context

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

Licence

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.

About

Dependency Free Audit Trailing Extension for Go and React

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages