@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 +192 -0
- package/package.json +32 -0
- package/src/console.integration.js +41 -0
- package/src/grafana-loki.integration.js +68 -0
- package/src/grafana-metrics.integration.js +223 -0
- package/src/index.js +6 -0
- package/src/logger.js +92 -0
- package/src/sentry.integration.js +41 -0
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
|
+
}
|