loghqorg/loghq-stacks
publicClone
Push over the same URL. A password will not work: create a token under access tokens and use it in place of one.
- .github
- src
- test
- .gitignore 158 B
- build.ts 3.5 KB
- bun.lock 6.4 KB
- deps.yaml 32 B
- LICENSE 1.0 KB
- package.json 1.5 KB
- README.md 14.1 KB
- tsconfig.json 366 B
@loghq/stacks
Streams a Stacks application's logs into
loghq. It wraps the framework's log facade, so every
log.info(...) already in your codebase becomes a searchable, correlated log
entry without touching a single call site.
bun add @loghq/stacksNo dependencies. Runs on Bun and Node 18+. The @stacksjs/* packages are
optional at runtime, so nothing breaks if you pull this into a plain script.
Quick start
Declare a transport in config/logging.ts:
// config/logging.ts
import { loghqTransport } from '@loghq/stacks'
import { storagePath } from '@stacksjs/path'
export default {
logsPath: storagePath('logs/stacks.log'),
deploymentsPath: storagePath('logs/deployments.log'),
transports: [
loghqTransport({
key: process.env.LOGHQ_KEY,
environment: process.env.APP_ENV,
release: process.env.APP_VERSION,
}),
],
} satisfies LoggingConfigThat is the whole integration. Keep logging exactly as you were:
import { log } from '@stacksjs/logging'
await log.info('checkout started', { orderId: 42 })
await log.error(new Error('gateway timeout'))Config is the right home for this, and not only on taste. It applies to every
process that boots the framework, so your scheduler and queue workers ship
their logs too, which a bootstrap import in app/Routes.ts would miss. Nothing
is monkey-patched, so there is no ordering window in which lines are lost. And
crash reports arrive without extra wiring: the framework's own
uncaughtException and unhandledRejection handlers funnel through report(),
which calls log.error, which reaches this transport like any other line.
On an older framework version
transports needs a framework version that has it. Without one, wire it
yourself with a side-effect import that runs early:
// app/Bootstrap.ts
import { install } from '@loghq/stacks'
install({ key: process.env.LOGHQ_KEY })// app/Routes.ts
import './Bootstrap'
// ... your existing route registrationsinstall() probes for the transport registry first and falls back to teeing
the mutable log singleton, so the same call works on both. Two things are
true of the fallback that are not true of the config path: anything logged
before install() runs is never captured, and it only covers the process that
imported it.
You can pass a single dsn instead of key plus host:
install({ dsn: 'https://loghq_your_key@loghq.org' })Manual capture
install() returns the client, and the same operations are exported at module
level:
import { capture, captureException, close, flush, setUser } from '@loghq/stacks'
setUser({ id: 8821, email: 'jane@acme.com' })
capture('critical', 'replica lag over threshold', { lagMs: 9400 })
try { await work() }
catch (err) { captureException(err, { job: 'nightly' }); throw err }
await flush() // drain the queue now
await close() // flush, then stop accepting, on shutdownWhat gets captured
- Every
log.*call.info,success,warn,warning,error, anddebug. Arguments are read raw, before clarity formats them, so an object logged as an object stays an object incontextinstead of arriving as a pretty-printed string. log.structevents.request,query,slowQuery,job, andcacheall route through the samelogmethods, so the wrap picks them up with no extra wiring. Astruct.requestthat ends in a 5xx arrives atlog.erroras one structured object;struct.slowQueryarrives atlog.warnas('[db.slow_query]', { event, sql, durationMs }). Worth knowing: nothing in the framework callslog.structyet, so this is a hook waiting for callers rather than a stream you will see today.- Uncaught exceptions, via
process.on('uncaughtException'). - Unhandled promise rejections, via
process.on('unhandledRejection').
Deliberately not captured: log.dump, log.dd, log.echo, and log.time.
Those are interactive debugging tools, not log records, and shipping them to a
log service would be noise. Also not captured: anything emitted before
install() runs.
Configuration
Every option is optional except key (or a dsn carrying one).
| Option | Type | Default | Notes |
|---|---|---|---|
key | string | required | Project ingest key, loghq_<64 hex>. Public by design: it identifies a project, is revocable, and grants no read access, so it is safe in app config. |
dsn | string | none | https://<key>@<host>. An alternative to key plus host. An explicit key or host wins over the DSN part. |
host | string | https://loghq.org | Only set it to self-host. See the note below. |
environment | string | APP_ENV, then NODE_ENV, else production | |
release | string | APP_VERSION or RELEASE when set | Version or commit the entry came from. |
channel | string | none | Applied to entries that do not carry their own. |
hostname | string | os.hostname() | Reporting machine. |
user | LogHQUser | null | null | Attributed to every entry until changed with setUser(). |
minLevel | LogHQLevel | info | Anything less severe is dropped before it is queued. Set debug to capture everything. |
enabled | boolean | true | Master switch. |
batchSize | number | 50 | Flush when the queue reaches this many entries. The server caps a request at 500. |
flushInterval | number | 2000 | Flush at least this often, in ms. 0 disables the timer. |
maxQueueSize | number | 1000 | Hard ceiling on queued entries. Oldest are discarded past it, so a logging burst cannot grow the process out of memory. |
timeout | number | 10000 | Per-request timeout, in ms. |
maxRetries | number | 5 | Transport attempts before a batch is abandoned. |
beforeSend | (entry) => entry | null | none | Last chance to redact, enrich, or drop. Return null to discard the entry. Throwing is caught and treated as null. |
debug | boolean | false | SDK diagnostics. Written to console only, never through the host logger. |
sdk | { name, version } | loghq.stacks at this package's version | Override the SDK identity. Integrations set this; applications should not. |
[!NOTE] Entries POST to
{host}/logs, so the default resolves tohttps://loghq.org/logs. The path comes from the ingest spec rather than from this package, and is identical in every language client.Leave
hostalone unless you self-host. The SDK owning the endpoint is what stops it being copied into every consuming application and having to be found again when it changes.To develop against a local loghq (
bun run devin the loghq repo), sethosttohttp://127.0.0.1:3008. Use the IPv4 literal rather thanlocalhost: that server binds 127.0.0.1 only, andlocalhostcan resolve to::1first and be refused.
Levels
loghq speaks the eight RFC 5424 / PSR-3 severities. clarity, the logger behind Stacks, has five. The two sets do not line up, so the bridge is lossy in both directions and this is exactly how it resolves:
| Stacks call | loghq level | Note |
|---|---|---|
log.debug() | debug | Below the default minLevel, so set minLevel: 'debug' to keep it. |
log.info() | info | |
log.success() | info | context.original_level is set to success. |
log.warn() / log.warning() | warning | |
log.error() | error |
A Stacks app cannot currently emit notice, critical, alert, or
emergency through log. There is no source for them in clarity's level set,
and this package does not invent one. If you need those severities, send them
deliberately with capture('critical', ...), which takes any of the eight.
success maps to info rather than notice on purpose. notice means "normal
but significant", and a success message is not reliably either one, so the
mapping keeps the severity honest and preserves the original name in
context.original_level instead.
Correlation
The Stacks router keeps a per-request context in AsyncLocalStorage, and
getLogContext() reads it. Every entry is stamped at enqueue time with whatever
is in there: trace_id becomes loghq's trace_id, requestId becomes
request_id. A line written deep inside a controller carries both without you
threading anything through your own code.
Outside a request (a queue worker, a scheduled command, boot) the lookup returns nothing and both fields are omitted rather than faked.
In loghq these are join keys, not grouping keys. Nothing is collapsed or deduplicated; the ids exist so one line expands into everything that happened around it:
GET /api/projects/{id}/logs?trace=<trace_id>
GET /api/projects/{id}/logs?request=<request_id>Use the 32-hex trace id from a W3C traceparent header when you have one, so
traces line up with other OpenTelemetry-aware tools.
Reliability
Batching. Entries queue in memory and flush when the queue reaches
batchSize or flushInterval elapses, whichever comes first. Your log call is
never blocked or awaited on the network: the enqueue is synchronous and returns
immediately, and the original logger method still runs first.
Backpressure. maxQueueSize is a hard ceiling. Past it the oldest queued
entries are discarded, because a log service being slow must never turn into an
out-of-memory kill in your application.
Retry. Transport failures and 5xx responses back off exponentially, up to
maxRetries. A 429 honors Retry-After. A 413 splits the batch, and a
single entry that still will not fit is dropped rather than retried forever.
Permanent stop. Some statuses never become success by retrying, so the client disables itself instead of producing a self-inflicted flood:
| Response | disabledReason() |
|---|---|
401 invalid ingest key | auth |
403 project inactive or no ingest key | inactive-project |
404 unknown project | unknown-project |
after close() | closed |
From then on isEnabled() is false and enqueueing is a no-op. If logs stop
arriving, check disabledReason() first.
Reconciliation. A 201 returns { ok, stored, dropped, skipped }, and a
201 does not mean every entry landed. The client compares those counts against
what it sent and warns locally when they disagree.
No recursion. This is the one rule the SDK will not bend. It is draining the
host logger, so routing its own failures back through log.* would feed itself
forever. Every diagnostic goes straight to console, and only when debug is
true. If loghq is unreachable, your application logs behave exactly as they did
before you installed this.
Two seams, and why
@stacksjs/logging grew a transports API: a transports key on
config/logging.ts and a registerTransport() export. That is the seam this
package prefers, and the one loghqTransport() uses. The logger hands every
record to the transport before formatting, so an Error is still an Error and
a context object is still an object, rather than both being flattened into the
console line.
Before that existed, the only hook was clarity's formatter, which is the wrong
shape: it is called for its return value, on the finished string, after the
level has been flattened. Building structured output through it means parsing
back out what the logger just finished formatting.
So the fallback is teeing the mutable log singleton, which Stacks exports as a
plain object with reassignable methods. install() keeps that intervention as
small as it can: the wrapper calls the original first, returns exactly what it
returned, enqueues a copy after, and is reversible by identity. log.struct
routes back through those same methods, so wrapping six functions captures the
structured stream too.
install() probes for the registry first and only tees if it finds none, so the
same call works on any framework version and upgrades itself when the app does.
Proving it attached
Neither cheap signal is the one you want. activeSeam() reports only what
install() built, so on the declarative seam it answers none even though the
transport is live and delivering. And a transport that constructed cleanly
proves only that a config file was evaluated — on a framework without transport
support the config loads, this package loads, loghqTransport() returns a
perfectly good transport, and not one record is ever delivered. Since 0.2.3
carries no peerDependencies, nothing upstream warns about that either.
verifyAttached() asks the logger which transports it actually holds, and then
asks that transport's client whether it will send. Those are two different
failures and you want both:
import { verifyAttached } from '@loghq/stacks'
const a = await verifyAttached()
if (a.seam === 'none')
throw new Error('loghq declared but not attached — check the framework version')
if (!a.live)
throw new Error(`loghq attached but dead: ${a.disabledReason ?? 'disabled in config'}`)The second check is not paranoia. A transport with no ingest key attaches
perfectly — the logger holds it and hands it every record, and it drops all of
them, because the client disabled itself the moment it was built with an empty
key. disabledReason is auth in exactly that case, which is what an app looks
like when LOGHQ_KEY never reached the box. Config files are also evaluated
early, sometimes before the environment is populated, so an empty key at
construction is a live hazard rather than a hypothetical one.
It covers both seams: a live install() tee reports tee, a declared transport
the logger picked up reports transport. Pass name if you registered the
transport under one, and logger to interrogate a specific logger instead of
probing for @stacksjs/logging.
Worth running at boot in production. It is the only falsifier this package has.
Roadmap
Severity is still lossy in one direction. Stacks has five levels, including
success, and loghq has the eight RFC 5424 severities. success maps to info
with the original kept in context.original_level, and notice, critical,
alert, and emergency have no source at all: a Stacks app cannot currently
emit them. That is a framework limitation this package will not paper over by
guessing.
License
MIT