@ossy/observability 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,192 @@
1
+ # @ossy/observability
2
+
3
+ Structured logger with pluggable backends for the Ossy platform.
4
+
5
+ ## `createLogger(namespace)`
6
+
7
+ Returns a logger bound to a namespace string. All log methods accept an optional `context` object that is merged into the output.
8
+
9
+ ```js
10
+ import { createLogger } from '@ossy/observability'
11
+
12
+ const log = createLogger('my-service')
13
+
14
+ log.info('Server started', { port: 3000 })
15
+ log.warn('Deprecated config key', { key: 'FOO' })
16
+ log.error('Request failed', { url: '/api/foo' }, error)
17
+ log.debug('Processing item', { id: '123' })
18
+ ```
19
+
20
+ ### Methods
21
+
22
+ | Method | Signature |
23
+ |--------|-----------|
24
+ | `log.info` | `(message: string, context?: object) => void` |
25
+ | `log.warn` | `(message: string, context?: object) => void` |
26
+ | `log.error` | `(message: string, context?: object, error?: Error) => void` |
27
+ | `log.debug` | `(message: string, context?: object) => void` |
28
+
29
+ ## Output format
30
+
31
+ - **Development** (`NODE_ENV !== 'production'`): pretty-printed lines with a `[namespace]` prefix.
32
+ - **Production** (`NODE_ENV === 'production'`): newline-delimited JSON:
33
+ ```json
34
+ { "level": "info", "namespace": "my-service", "message": "Server started", "port": 3000, "timestamp": "2026-01-01T00:00:00.000Z" }
35
+ ```
36
+
37
+ ## Adding a custom backend
38
+
39
+ A backend is an object with four functions. Register it with `registerBackend`:
40
+
41
+ ```js
42
+ import { registerBackend } from '@ossy/observability'
43
+
44
+ registerBackend({
45
+ info(namespace, message, context) { /* ... */ },
46
+ warn(namespace, message, context) { /* ... */ },
47
+ error(namespace, message, context, error) { /* ... */ },
48
+ debug(namespace, message, context) { /* ... */ },
49
+ })
50
+ ```
51
+
52
+ All registered backends receive every log call (fan-out). The built-in console backend is always active unless you call `clearBackends()` first.
53
+
54
+ ## Built-in integrations
55
+
56
+ ### `console.integration.js`
57
+
58
+ | Field | Value |
59
+ |-------|-------|
60
+ | `id` | `logger-console` |
61
+ | `credentials` | *(none)* |
62
+
63
+ Provides the same pretty/JSON output as the default backend, but as a discoverable `*.integration.js` primitive. Use it when you want the platform's integration loader to manage the console backend explicitly:
64
+
65
+ ```js
66
+ import { consoleIntegration } from '@ossy/observability'
67
+ import { registerBackend, clearBackends } from '@ossy/observability'
68
+
69
+ clearBackends()
70
+ registerBackend(await consoleIntegration.connect({ env: process.env }))
71
+ ```
72
+
73
+ ### `sentry.integration.js`
74
+
75
+ | Field | Value |
76
+ |-------|-------|
77
+ | `id` | `logger-sentry` |
78
+ | `credentials` | `SENTRY_DSN` |
79
+
80
+ Forwards `error`-level calls to Sentry via `captureException`. `info`, `warn`, and `debug` are no-ops in this backend.
81
+
82
+ Add the integration file to your app's manifest (or import it directly) and ensure `SENTRY_DSN` is set in the environment:
83
+
84
+ ```js
85
+ import { sentryIntegration } from '@ossy/observability'
86
+ import { registerBackend } from '@ossy/observability'
87
+
88
+ registerBackend(await sentryIntegration.connect({ env: process.env }))
89
+ ```
90
+
91
+ ### `grafana-loki.integration.js`
92
+
93
+ | Field | Value |
94
+ |-------|-------|
95
+ | `id` | `logger-grafana-loki` |
96
+ | `credentials` | `GRAFANA_LOKI_URL`, `GRAFANA_LOKI_USER`, `GRAFANA_LOKI_API_KEY` |
97
+
98
+ Forwards every log call to a Grafana Loki instance as a structured JSON stream. Calls are fire-and-forget — Loki outages never block application code.
99
+
100
+ Each log entry is pushed as a Loki stream labelled with `level` and `namespace`, making it queryable in Grafana Explore:
101
+
102
+ ```logql
103
+ {namespace="my-service"} | json
104
+ {level="error"}
105
+ ```
106
+
107
+ **Setup:**
108
+
109
+ ```js
110
+ import { grafanaLokiIntegration, registerBackend } from '@ossy/observability'
111
+
112
+ registerBackend(await grafanaLokiIntegration.connect({ env: process.env }))
113
+ ```
114
+
115
+ **Required environment variables:**
116
+
117
+ | Variable | Description |
118
+ |----------|-------------|
119
+ | `GRAFANA_LOKI_URL` | Full Loki push endpoint, e.g. `https://logs-prod-eu-west-0.grafana.net/loki/api/v1/push` |
120
+ | `GRAFANA_LOKI_USER` | Numeric user ID from the Grafana Cloud Loki data source page |
121
+ | `GRAFANA_LOKI_API_KEY` | Grafana Cloud API key with the **MetricsPublisher** role |
122
+
123
+ ---
124
+
125
+ ### `grafana-metrics.integration.js`
126
+
127
+ | Field | Value |
128
+ |-------|-------|
129
+ | `id` | `metrics-grafana` |
130
+ | `credentials` | `GRAFANA_METRICS_URL`, `GRAFANA_METRICS_USER`, `GRAFANA_METRICS_API_KEY` |
131
+
132
+ Batches counter, gauge, and histogram observations in memory and flushes them every 10 seconds as a Prometheus text-format body to the configured push endpoint. No extra npm dependencies — uses native `fetch` only.
133
+
134
+ **Setup:**
135
+
136
+ ```js
137
+ import { grafanaMetricsIntegration } from '@ossy/observability'
138
+
139
+ await grafanaMetricsIntegration.connect({ env: process.env })
140
+ ```
141
+
142
+ Call `connect()` once at startup. This wires up the `metrics` singleton exported from the package and starts the flush interval.
143
+
144
+ **Using the metrics client:**
145
+
146
+ ```js
147
+ import { metrics } from '@ossy/observability'
148
+
149
+ metrics.increment('task.run', { task: 'resize-image' }) // counter
150
+ metrics.gauge('queue.depth', 42, { queue: 'images' }) // gauge
151
+ metrics.timing('task.duration', 1234, { task: 'resize-image' }) // histogram (ms)
152
+ ```
153
+
154
+ Metric names use dots in application code (`task.run`) and are normalised to underscores (`task_run`) in the Prometheus output.
155
+
156
+ **Required environment variables:**
157
+
158
+ | Variable | Description |
159
+ |----------|-------------|
160
+ | `GRAFANA_METRICS_URL` | Prometheus-compatible push endpoint, e.g. `https://prometheus-prod-13-prod-us-east-0.grafana.net/api/prom/push` |
161
+ | `GRAFANA_METRICS_USER` | Numeric user ID from the Grafana Cloud Prometheus data source page |
162
+ | `GRAFANA_METRICS_API_KEY` | Grafana Cloud API key with the **MetricsPublisher** role |
163
+
164
+ **What appears in Grafana:**
165
+
166
+ | Prometheus metric | Type | Labels |
167
+ |-------------------|------|--------|
168
+ | `task_run` | counter | `task` |
169
+ | `task_duration_bucket` / `_sum` / `_count` | histogram | `task` |
170
+
171
+ Histogram buckets are pre-defined at 10 ms, 50 ms, 100 ms, 250 ms, 500 ms, 1 s, 2.5 s, 5 s, 10 s, and +Inf. Use a PromQL query like the following to graph p95 task duration:
172
+
173
+ ```promql
174
+ histogram_quantile(0.95, sum by (le, task) (rate(task_duration_bucket[5m])))
175
+ ```
176
+
177
+ ---
178
+
179
+ ## How tasks receive `log` automatically
180
+
181
+ `@ossy/platform`'s `TaskService` injects a `log` instance into every task's `run()` call, scoped to the task's `metadata.id`:
182
+
183
+ ```js
184
+ // your-task.js
185
+ export const metadata = { id: 'my-task', triggers: [...] }
186
+
187
+ export async function run({ event, sdk, integrations, log }) {
188
+ log.info('Task started', { eventType: event.type })
189
+ }
190
+ ```
191
+
192
+ No extra setup needed — the logger is created automatically by the platform before dispatching the task.
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@ossy/observability",
3
+ "version": "1.1.0",
4
+ "description": "Structured logger with pluggable backends for the Ossy platform",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/ossy-se/packages.git"
8
+ },
9
+ "type": "module",
10
+ "ossy": {
11
+ "src": "./src"
12
+ },
13
+ "main": "./src/index.js",
14
+ "exports": {
15
+ ".": "./src/index.js"
16
+ },
17
+ "private": false,
18
+ "publishConfig": {
19
+ "access": "public"
20
+ },
21
+ "keywords": [],
22
+ "author": "Ossy <yourfriends@ossy.se> (https://ossy.se)",
23
+ "license": "MIT",
24
+ "dependencies": {
25
+ "@sentry/node": "^8"
26
+ },
27
+ "files": [
28
+ "src",
29
+ "README.md"
30
+ ],
31
+ "gitHead": "9a8a1bb0466d35001425d241d77c2c9ab31e11d3"
32
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Console backend for @ossy/observability.
3
+ *
4
+ * In development: pretty-printed lines with a `[namespace]` prefix.
5
+ * In production: newline-delimited JSON: { level, namespace, message, ...context, timestamp }.
6
+ *
7
+ * Credentials: none — always available.
8
+ */
9
+
10
+ export const id = 'logger-console'
11
+ export const credentials = []
12
+
13
+ /**
14
+ * @param {{ env: NodeJS.ProcessEnv }} opts
15
+ * @returns {Promise<import('./logger.js').Backend>}
16
+ */
17
+ export async function connect ({ env }) {
18
+ const isProd = env.NODE_ENV === 'production'
19
+
20
+ function emit (level, ns, msg, ctx, err) {
21
+ if (isProd) {
22
+ const entry = { level, namespace: ns, message: msg, ...ctx, timestamp: new Date().toISOString() }
23
+ if (err) entry.error = err.stack ?? err.message
24
+ const stream = level === 'error' ? process.stderr : process.stdout
25
+ stream.write(JSON.stringify(entry) + '\n')
26
+ } else {
27
+ const prefix = `[${ns}]`
28
+ if (level === 'info') console.log(prefix, msg, ...(ctx ? [ctx] : []))
29
+ else if (level === 'warn') console.warn(prefix, msg, ...(ctx ? [ctx] : []))
30
+ else if (level === 'error') console.error(prefix, msg, ...(ctx ? [ctx] : []), ...(err ? [err] : []))
31
+ else if (level === 'debug') console.debug(prefix, msg, ...(ctx ? [ctx] : []))
32
+ }
33
+ }
34
+
35
+ return {
36
+ info: (ns, msg, ctx) => emit('info', ns, msg, ctx),
37
+ warn: (ns, msg, ctx) => emit('warn', ns, msg, ctx),
38
+ error: (ns, msg, ctx, err) => emit('error', ns, msg, ctx, err),
39
+ debug: (ns, msg, ctx) => emit('debug', ns, msg, ctx),
40
+ }
41
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Grafana Loki backend for @ossy/observability.
3
+ *
4
+ * Pushes every log entry to a Loki instance via the HTTP push API
5
+ * (POST /loki/api/v1/push). Each call is fire-and-forget — network
6
+ * errors are swallowed so a Loki outage never blocks application code.
7
+ *
8
+ * Required credentials:
9
+ * GRAFANA_LOKI_URL — e.g. https://logs-prod-eu-west-0.grafana.net/loki/api/v1/push
10
+ * GRAFANA_LOKI_USER — numeric user ID shown in Grafana Cloud
11
+ * GRAFANA_LOKI_API_KEY — Grafana Cloud API key with MetricsPublisher role
12
+ */
13
+
14
+ export const id = 'logger-grafana-loki'
15
+ export const credentials = [
16
+ 'GRAFANA_LOKI_URL',
17
+ 'GRAFANA_LOKI_USER',
18
+ 'GRAFANA_LOKI_API_KEY',
19
+ ]
20
+
21
+ /**
22
+ * @param {{ env: NodeJS.ProcessEnv }} opts
23
+ * @returns {Promise<import('./logger.js').Backend>}
24
+ */
25
+ export async function connect ({ env }) {
26
+ const url = env.GRAFANA_LOKI_URL
27
+ const user = env.GRAFANA_LOKI_USER
28
+ const key = env.GRAFANA_LOKI_API_KEY
29
+ const auth = 'Basic ' + Buffer.from(`${user}:${key}`).toString('base64')
30
+
31
+ function push (level, ns, msg, ctx, err) {
32
+ const entry = {
33
+ level,
34
+ namespace: ns,
35
+ message: msg,
36
+ ...ctx,
37
+ ...(err ? { error: err.stack ?? err.message } : {}),
38
+ timestamp: new Date().toISOString(),
39
+ }
40
+
41
+ // Loki expects nanosecond Unix timestamps as strings.
42
+ const unixNs = String(Date.now() * 1_000_000)
43
+
44
+ const body = JSON.stringify({
45
+ streams: [{
46
+ stream: { level, namespace: ns },
47
+ values: [[unixNs, JSON.stringify(entry)]],
48
+ }],
49
+ })
50
+
51
+ // fire-and-forget — never await, never rethrow
52
+ fetch(url, {
53
+ method: 'POST',
54
+ headers: {
55
+ 'Content-Type': 'application/json',
56
+ Authorization: auth,
57
+ },
58
+ body,
59
+ }).catch(() => {})
60
+ }
61
+
62
+ return {
63
+ info: (ns, msg, ctx) => push('info', ns, msg, ctx),
64
+ warn: (ns, msg, ctx) => push('warn', ns, msg, ctx),
65
+ error: (ns, msg, ctx, err) => push('error', ns, msg, ctx, err),
66
+ debug: (ns, msg, ctx) => push('debug', ns, msg, ctx),
67
+ }
68
+ }
@@ -0,0 +1,223 @@
1
+ /**
2
+ * Grafana Cloud metrics backend for @ossy/observability.
3
+ *
4
+ * Batches counter / gauge / histogram observations in memory and flushes them
5
+ * every 10 s as a Prometheus text-format body (no protobuf required) to the
6
+ * configured push endpoint (Prometheus Pushgateway or Grafana Cloud Mimir
7
+ * compatible push URL).
8
+ *
9
+ * Required credentials:
10
+ * GRAFANA_METRICS_URL — remote_write / pushgateway endpoint
11
+ * GRAFANA_METRICS_USER — numeric user ID shown in Grafana Cloud
12
+ * GRAFANA_METRICS_API_KEY — Grafana Cloud API key with MetricsPublisher role
13
+ *
14
+ * The exported `metrics` singleton starts as a no-op object. Call connect()
15
+ * once at startup to wire up the real implementation.
16
+ */
17
+
18
+ export const id = 'metrics-grafana'
19
+ export const credentials = [
20
+ 'GRAFANA_METRICS_URL',
21
+ 'GRAFANA_METRICS_USER',
22
+ 'GRAFANA_METRICS_API_KEY',
23
+ ]
24
+
25
+ /**
26
+ * Singleton metrics client. No-ops until connect() is called.
27
+ *
28
+ * @type {{
29
+ * increment(name: string, labels?: Record<string, string>): void,
30
+ * gauge(name: string, value: number, labels?: Record<string, string>): void,
31
+ * timing(name: string, ms: number, labels?: Record<string, string>): void,
32
+ * }}
33
+ */
34
+ export const metrics = {
35
+ increment (_name, _labels) {},
36
+ gauge (_name, _value, _labels) {},
37
+ timing (_name, _ms, _labels) {},
38
+ }
39
+
40
+ // Predefined histogram bucket boundaries (milliseconds).
41
+ const HISTOGRAM_BUCKETS_MS = [10, 50, 100, 250, 500, 1000, 2500, 5000, 10000, Infinity]
42
+
43
+ /**
44
+ * Normalise a metric name: dots → underscores (Prometheus convention).
45
+ * @param {string} name
46
+ */
47
+ function safeName (name) {
48
+ return name.replace(/\./g, '_')
49
+ }
50
+
51
+ /**
52
+ * Render a label set as a Prometheus selector string, e.g. `{task="foo"}`.
53
+ * Returns an empty string when there are no labels.
54
+ * @param {Record<string, string> | undefined} labels
55
+ */
56
+ function labelsStr (labels) {
57
+ if (!labels || !Object.keys(labels).length) return ''
58
+ return '{' + Object.entries(labels)
59
+ .sort(([a], [b]) => a.localeCompare(b))
60
+ .map(([k, v]) => `${k}="${v}"`)
61
+ .join(',') + '}'
62
+ }
63
+
64
+ /**
65
+ * Like labelsStr but appends the `le` label used in histogram buckets.
66
+ * @param {Record<string, string> | undefined} labels
67
+ * @param {string} leStr
68
+ */
69
+ function labelsWithLe (labels, leStr) {
70
+ const sorted = labels
71
+ ? Object.entries(labels).sort(([a], [b]) => a.localeCompare(b)).map(([k, v]) => `${k}="${v}"`)
72
+ : []
73
+ return '{' + [...sorted, `le="${leStr}"`].join(',') + '}'
74
+ }
75
+
76
+ /**
77
+ * Build a stable Map key from metric name + label set.
78
+ * @param {string} name
79
+ * @param {Record<string, string> | undefined} labels
80
+ */
81
+ function makeKey (name, labels) {
82
+ return name + labelsStr(labels)
83
+ }
84
+
85
+ /**
86
+ * @param {{ env: NodeJS.ProcessEnv }} opts
87
+ * @returns {Promise<typeof metrics>}
88
+ */
89
+ export async function connect ({ env }) {
90
+ const url = env.GRAFANA_METRICS_URL
91
+ const user = env.GRAFANA_METRICS_USER
92
+ const key = env.GRAFANA_METRICS_API_KEY
93
+ const auth = 'Basic ' + Buffer.from(`${user}:${key}`).toString('base64')
94
+
95
+ // In-memory accumulators — all values are cumulative (Prometheus semantics).
96
+ /** @type {Map<string, { name: string, labels: Record<string,string>|undefined, value: number }>} */
97
+ const counters = new Map()
98
+ /** @type {Map<string, { name: string, labels: Record<string,string>|undefined, value: number }>} */
99
+ const gauges = new Map()
100
+ /**
101
+ * @type {Map<string, {
102
+ * name: string,
103
+ * labels: Record<string,string>|undefined,
104
+ * sum: number,
105
+ * count: number,
106
+ * buckets: Map<number, number>
107
+ * }>}
108
+ */
109
+ const histograms = new Map()
110
+
111
+ function buildTextBody () {
112
+ const lines = []
113
+
114
+ // Counters grouped by name (one TYPE declaration per metric name).
115
+ const countersByName = new Map()
116
+ for (const entry of counters.values()) {
117
+ const n = safeName(entry.name)
118
+ if (!countersByName.has(n)) countersByName.set(n, [])
119
+ countersByName.get(n).push(entry)
120
+ }
121
+ for (const [n, entries] of countersByName) {
122
+ lines.push(`# TYPE ${n} counter`)
123
+ for (const { labels, value } of entries) {
124
+ lines.push(`${n}${labelsStr(labels)} ${value}`)
125
+ }
126
+ }
127
+
128
+ // Gauges.
129
+ const gaugesByName = new Map()
130
+ for (const entry of gauges.values()) {
131
+ const n = safeName(entry.name)
132
+ if (!gaugesByName.has(n)) gaugesByName.set(n, [])
133
+ gaugesByName.get(n).push(entry)
134
+ }
135
+ for (const [n, entries] of gaugesByName) {
136
+ lines.push(`# TYPE ${n} gauge`)
137
+ for (const { labels, value } of entries) {
138
+ lines.push(`${n}${labelsStr(labels)} ${value}`)
139
+ }
140
+ }
141
+
142
+ // Histograms.
143
+ const histsByName = new Map()
144
+ for (const entry of histograms.values()) {
145
+ const n = safeName(entry.name)
146
+ if (!histsByName.has(n)) histsByName.set(n, [])
147
+ histsByName.get(n).push(entry)
148
+ }
149
+ for (const [n, entries] of histsByName) {
150
+ lines.push(`# TYPE ${n} histogram`)
151
+ for (const { labels, sum, count, buckets } of entries) {
152
+ for (const [le, n2] of buckets) {
153
+ const leStr = le === Infinity ? '+Inf' : String(le)
154
+ lines.push(`${n}_bucket${labelsWithLe(labels, leStr)} ${n2}`)
155
+ }
156
+ lines.push(`${n}_sum${labelsStr(labels)} ${sum}`)
157
+ lines.push(`${n}_count${labelsStr(labels)} ${count}`)
158
+ }
159
+ }
160
+
161
+ return lines.join('\n') + '\n'
162
+ }
163
+
164
+ async function flush () {
165
+ if (!counters.size && !gauges.size && !histograms.size) return
166
+
167
+ const body = buildTextBody()
168
+
169
+ try {
170
+ await fetch(url, {
171
+ method: 'POST',
172
+ headers: {
173
+ 'Content-Type': 'text/plain',
174
+ Authorization: auth,
175
+ },
176
+ body,
177
+ })
178
+ } catch {
179
+ // swallow — a metrics outage must never crash the application
180
+ }
181
+ }
182
+
183
+ const interval = setInterval(flush, 10_000)
184
+ // Allow the process to exit cleanly without waiting for the flush interval.
185
+ if (interval.unref) interval.unref()
186
+
187
+ // Replace the singleton no-op methods with real implementations.
188
+ metrics.increment = function (name, labels) {
189
+ const key = makeKey(name, labels)
190
+ const existing = counters.get(key)
191
+ if (existing) {
192
+ existing.value += 1
193
+ } else {
194
+ counters.set(key, { name, labels, value: 1 })
195
+ }
196
+ }
197
+
198
+ metrics.gauge = function (name, value, labels) {
199
+ gauges.set(makeKey(name, labels), { name, labels, value })
200
+ }
201
+
202
+ metrics.timing = function (name, ms, labels) {
203
+ const key = makeKey(name, labels)
204
+ let h = histograms.get(key)
205
+ if (!h) {
206
+ h = {
207
+ name,
208
+ labels,
209
+ sum: 0,
210
+ count: 0,
211
+ buckets: new Map(HISTOGRAM_BUCKETS_MS.map(le => [le, 0])),
212
+ }
213
+ histograms.set(key, h)
214
+ }
215
+ h.sum += ms
216
+ h.count += 1
217
+ for (const le of h.buckets.keys()) {
218
+ if (ms <= le) h.buckets.set(le, h.buckets.get(le) + 1)
219
+ }
220
+ }
221
+
222
+ return metrics
223
+ }
package/src/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { createLogger, registerBackend, clearBackends } from './logger.js'
2
+ export * as consoleIntegration from './console.integration.js'
3
+ export * as sentryIntegration from './sentry.integration.js'
4
+ export * as grafanaLokiIntegration from './grafana-loki.integration.js'
5
+ export * as grafanaMetricsIntegration from './grafana-metrics.integration.js'
6
+ export { metrics } from './grafana-metrics.integration.js'
package/src/logger.js ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * @ossy/observability — structured logger with pluggable backends.
3
+ *
4
+ * Usage:
5
+ * import { createLogger } from '@ossy/observability'
6
+ * const log = createLogger('my-namespace')
7
+ * log.info('Server started', { port: 3000 })
8
+ */
9
+
10
+ /** @type {Array<Backend>} */
11
+ const _backends = []
12
+
13
+ /**
14
+ * @typedef {{ level: string, namespace: string, message: string, context?: object, error?: Error, timestamp: string }} LogEntry
15
+ * @typedef {{ info(ns: string, msg: string, ctx?: object): void, warn(ns: string, msg: string, ctx?: object): void, error(ns: string, msg: string, ctx?: object, err?: Error): void, debug(ns: string, msg: string, ctx?: object): void }} Backend
16
+ */
17
+
18
+ function _isProduction () {
19
+ return process.env.NODE_ENV === 'production'
20
+ }
21
+
22
+ /** Built-in fallback backend — always active unless explicitly replaced. */
23
+ const _defaultBackend = {
24
+ info (ns, msg, ctx) {
25
+ if (_isProduction()) {
26
+ process.stdout.write(JSON.stringify({ level: 'info', namespace: ns, message: msg, ...ctx, timestamp: new Date().toISOString() }) + '\n')
27
+ } else {
28
+ console.log(`[${ns}] ${msg}`, ctx ?? '')
29
+ }
30
+ },
31
+ warn (ns, msg, ctx) {
32
+ if (_isProduction()) {
33
+ process.stdout.write(JSON.stringify({ level: 'warn', namespace: ns, message: msg, ...ctx, timestamp: new Date().toISOString() }) + '\n')
34
+ } else {
35
+ console.warn(`[${ns}] ${msg}`, ctx ?? '')
36
+ }
37
+ },
38
+ error (ns, msg, ctx, err) {
39
+ if (_isProduction()) {
40
+ const entry = { level: 'error', namespace: ns, message: msg, ...ctx, timestamp: new Date().toISOString() }
41
+ if (err) entry.error = err.stack ?? err.message
42
+ process.stderr.write(JSON.stringify(entry) + '\n')
43
+ } else {
44
+ console.error(`[${ns}] ${msg}`, ctx ?? '', err ?? '')
45
+ }
46
+ },
47
+ debug (ns, msg, ctx) {
48
+ if (_isProduction()) return
49
+ console.debug(`[${ns}] ${msg}`, ctx ?? '')
50
+ },
51
+ }
52
+
53
+ _backends.push(_defaultBackend)
54
+
55
+ /**
56
+ * Add a backend to the fan-out list.
57
+ * All registered backends receive every log call.
58
+ * @param {Backend} backend
59
+ */
60
+ export function registerBackend (backend) {
61
+ _backends.push(backend)
62
+ }
63
+
64
+ /**
65
+ * Remove all currently registered backends.
66
+ * Useful when replacing the default with a custom console integration.
67
+ */
68
+ export function clearBackends () {
69
+ _backends.length = 0
70
+ }
71
+
72
+ /**
73
+ * Create a logger bound to the given namespace.
74
+ * @param {string} namespace
75
+ * @returns {{ info(msg: string, ctx?: object): void, warn(msg: string, ctx?: object): void, error(msg: string, ctx?: object, err?: Error): void, debug(msg: string, ctx?: object): void }}
76
+ */
77
+ export function createLogger (namespace) {
78
+ return {
79
+ info (message, context) {
80
+ for (const b of _backends) b.info(namespace, message, context)
81
+ },
82
+ warn (message, context) {
83
+ for (const b of _backends) b.warn(namespace, message, context)
84
+ },
85
+ error (message, context, error) {
86
+ for (const b of _backends) b.error(namespace, message, context, error)
87
+ },
88
+ debug (message, context) {
89
+ for (const b of _backends) b.debug(namespace, message, context)
90
+ },
91
+ }
92
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Sentry backend for @ossy/observability.
3
+ *
4
+ * Forwards `error`-level log calls to Sentry via captureException.
5
+ * info / warn / debug are no-ops in this backend.
6
+ *
7
+ * Required credentials: SENTRY_DSN
8
+ */
9
+
10
+ export const id = 'logger-sentry'
11
+ export const credentials = ['SENTRY_DSN']
12
+
13
+ /**
14
+ * @param {{ env: NodeJS.ProcessEnv }} opts
15
+ * @returns {Promise<import('./logger.js').Backend>}
16
+ */
17
+ export async function connect ({ env }) {
18
+ const Sentry = await import('@sentry/node')
19
+
20
+ Sentry.init({
21
+ dsn: env.SENTRY_DSN,
22
+ environment: env.NODE_ENV ?? 'development',
23
+ })
24
+
25
+ return {
26
+ info: () => {},
27
+ warn: () => {},
28
+ error: (_ns, msg, ctx, err) => {
29
+ if (err instanceof Error) {
30
+ Sentry.withScope((scope) => {
31
+ if (ctx) scope.setContext('log', ctx)
32
+ scope.setExtra('message', msg)
33
+ Sentry.captureException(err)
34
+ })
35
+ } else {
36
+ Sentry.captureMessage(msg, { level: 'error', extra: ctx })
37
+ }
38
+ },
39
+ debug: () => {},
40
+ }
41
+ }