@alex-michaud/pino-graylog-transport 1.0.2
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/LICENSE +22 -0
- package/README.md +489 -0
- package/dist/gelf-formatter.d.ts +11 -0
- package/dist/gelf-formatter.js +141 -0
- package/dist/graylog-transport.d.ts +55 -0
- package/dist/graylog-transport.js +216 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +39 -0
- package/dist/message-queue.d.ts +36 -0
- package/dist/message-queue.js +65 -0
- package/dist/socket-connection.d.ts +23 -0
- package/dist/socket-connection.js +116 -0
- package/dist/udp-client.d.ts +44 -0
- package/dist/udp-client.js +181 -0
- package/package.json +95 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alexandre Michaud
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
22
|
+
|
package/README.md
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
# pino-graylog-transport
|
|
2
|
+
|
|
3
|
+
A Pino transport module that sends log messages to Graylog using the GELF (Graylog Extended Log Format) protocol over TCP, TLS, or UDP.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- 🚀 Full support for Pino transports API
|
|
8
|
+
- 📦 GELF (Graylog Extended Log Format) message formatting
|
|
9
|
+
- 🔒 TLS, TCP, and UDP protocol support (TLS secure by default)
|
|
10
|
+
- 🔧 Configurable facility, host, and port
|
|
11
|
+
- 📊 Automatic log level conversion (Pino → Syslog)
|
|
12
|
+
- 🏷️ Custom field support with GELF underscore prefix
|
|
13
|
+
- ⚡ High-performance async message sending with buffering and reconnection logic
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm install @alex-michaud/pino-graylog-transport pino
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Publishing & Installation
|
|
22
|
+
|
|
23
|
+
This package is published under the scope `@alex-michaud`. To install, use the following command:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npm install @alex-michaud/pino-graylog-transport
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
If you encounter permissions issues, you may need to add `--access public` to the install command:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm install @alex-michaud/pino-graylog-transport --access public
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Quick Start
|
|
36
|
+
|
|
37
|
+
```javascript
|
|
38
|
+
const pino = require('pino');
|
|
39
|
+
const transport = require('@alex-michaud/pino-graylog-transport');
|
|
40
|
+
|
|
41
|
+
const transportInstance = transport({
|
|
42
|
+
host: 'graylog.example.com',
|
|
43
|
+
port: 12201,
|
|
44
|
+
// TLS is the default for secure transmission over networks
|
|
45
|
+
// Use protocol: 'tcp' only for local development (localhost)
|
|
46
|
+
protocol: 'tls',
|
|
47
|
+
facility: 'my-app',
|
|
48
|
+
staticMeta: {
|
|
49
|
+
environment: 'production',
|
|
50
|
+
service: 'api',
|
|
51
|
+
version: '1.0.0'
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const logger = pino(transportInstance);
|
|
56
|
+
|
|
57
|
+
logger.info('Hello Graylog!');
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Configuration Options
|
|
61
|
+
|
|
62
|
+
| Option | Type | Default | Description |
|
|
63
|
+
|--------|------|---------|-------------|
|
|
64
|
+
| `host` | string | `'localhost'` | Graylog server hostname |
|
|
65
|
+
| `port` | number | `12201` | Graylog GELF input port (standard GELF TCP port) |
|
|
66
|
+
| `protocol` | string | `'tls'` | Protocol to use (`'tcp'`, `'tls'`, or `'udp'`). **Default is `'tls'` for security** - uses encrypted connection to prevent exposure of sensitive data. Use `'tcp'` only for local development. Use `'udp'` for high-throughput scenarios where delivery guarantees are not required. |
|
|
67
|
+
| `facility` | string | `hostname` | **Application/service identifier** sent with every message. Used to categorize logs by application in Graylog (e.g., `'api-gateway'`, `'auth-service'`). Sent as `_facility` additional field per [GELF spec](https://go2docs.graylog.org/current/getting_in_log_data/gelf.html). |
|
|
68
|
+
| `hostname` | string | `os.hostname()` | Host field in GELF messages (the machine/server name) |
|
|
69
|
+
| `staticMeta` | object | `{}` | Static fields included in **every** log message (e.g., auth tokens, environment, datacenter). These are sent as GELF custom fields with underscore prefix. |
|
|
70
|
+
| `maxQueueSize` | number | `1000` | Max messages to queue when disconnected |
|
|
71
|
+
| `onError` | function | `console.error` | Custom error handler |
|
|
72
|
+
| `onReady` | function | `undefined` | Callback when connection is established |
|
|
73
|
+
| `autoConnect` | boolean | `true` | If `false`, don't establish connection on initialization. Only applies to TCP/TLS; UDP always initializes immediately since it's connectionless. |
|
|
74
|
+
|
|
75
|
+
### ⚠️ Security Note
|
|
76
|
+
|
|
77
|
+
The default protocol is **`tls`** to ensure logs are transmitted securely over encrypted connections. This is important when:
|
|
78
|
+
- Sending logs over untrusted networks (internet, shared corporate networks)
|
|
79
|
+
- Including authentication tokens in `staticMeta` (e.g., `'X-OVH-TOKEN'`)
|
|
80
|
+
- Logging sensitive data (PII, API keys, internal URLs)
|
|
81
|
+
|
|
82
|
+
Only use `protocol: 'tcp'` for local development when Graylog is running on `localhost`.
|
|
83
|
+
|
|
84
|
+
## Using with Authentication Tokens
|
|
85
|
+
|
|
86
|
+
Some Graylog services require authentication tokens to be sent with every log message. Use `staticMeta` to include these tokens and any other metadata that should be sent with **all** log messages:
|
|
87
|
+
|
|
88
|
+
```javascript
|
|
89
|
+
const transport = require('@alex-michaud/pino-graylog-transport');
|
|
90
|
+
|
|
91
|
+
// Example: OVH Logs Data Platform
|
|
92
|
+
const stream = transport({
|
|
93
|
+
host: 'bhs1.logs.ovh.com',
|
|
94
|
+
port: 12202,
|
|
95
|
+
protocol: 'tls',
|
|
96
|
+
staticMeta: {
|
|
97
|
+
'X-OVH-TOKEN': 'your-ovh-token-here'
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Example: Generic cloud provider with token
|
|
102
|
+
const stream = transport({
|
|
103
|
+
host: 'graylog.example.com',
|
|
104
|
+
port: 12201,
|
|
105
|
+
protocol: 'tls',
|
|
106
|
+
staticMeta: {
|
|
107
|
+
token: 'your-auth-token',
|
|
108
|
+
environment: 'production',
|
|
109
|
+
datacenter: 'us-east-1'
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
All fields in `staticMeta` will be included in every GELF message with an underscore prefix (e.g., `_X-OVH-TOKEN`, `_token`, `_environment`).
|
|
115
|
+
|
|
116
|
+
## Understanding Configuration Fields
|
|
117
|
+
|
|
118
|
+
### `facility` vs `hostname` vs `staticMeta`
|
|
119
|
+
|
|
120
|
+
These three configuration options serve different purposes:
|
|
121
|
+
|
|
122
|
+
| Field | Purpose | Example | GELF Field | When to Use |
|
|
123
|
+
|-------|---------|---------|------------|-------------|
|
|
124
|
+
| `facility` | **Application/service identifier** | `'api-gateway'`, `'auth-service'` | `_facility` | Identify which application/microservice sent the log |
|
|
125
|
+
| `hostname` | **Machine/server identifier** | `'web-server-01'`, `'us-east-1a'` | `host` | Identify which machine/container/pod sent the log |
|
|
126
|
+
| `staticMeta` | **Context metadata** | `{ token: 'abc', env: 'prod' }` | `_token`, `_env` | Add authentication tokens or contextual info |
|
|
127
|
+
|
|
128
|
+
### Example: Microservices Architecture
|
|
129
|
+
|
|
130
|
+
```javascript
|
|
131
|
+
// API Gateway service running on server 1
|
|
132
|
+
transport({
|
|
133
|
+
facility: 'api-gateway', // What service?
|
|
134
|
+
hostname: 'web-server-01', // Which machine?
|
|
135
|
+
staticMeta: {
|
|
136
|
+
environment: 'production', // Extra context
|
|
137
|
+
region: 'us-east-1',
|
|
138
|
+
version: '2.1.0'
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Auth Service running on server 2
|
|
143
|
+
transport({
|
|
144
|
+
facility: 'auth-service', // What service?
|
|
145
|
+
hostname: 'web-server-02', // Which machine?
|
|
146
|
+
staticMeta: {
|
|
147
|
+
environment: 'production',
|
|
148
|
+
region: 'us-east-1',
|
|
149
|
+
version: '1.5.3'
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
In Graylog, you can then:
|
|
155
|
+
- Filter by `_facility:api-gateway` to see all API gateway logs
|
|
156
|
+
- Filter by `host:web-server-01` to see all logs from that server
|
|
157
|
+
- Filter by `_environment:production` to see production logs across all services
|
|
158
|
+
|
|
159
|
+
## Local Development with Docker
|
|
160
|
+
|
|
161
|
+
This repository includes a Docker Compose configuration to run Graylog locally for testing.
|
|
162
|
+
|
|
163
|
+
### Start Graylog
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
npm run docker:up
|
|
167
|
+
# or
|
|
168
|
+
docker compose up -d
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Wait about 30 seconds for Graylog to fully start, then run the setup script to create the GELF inputs:
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
npm run docker:setup
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
Then access the web interface at:
|
|
178
|
+
- URL: http://localhost:9005
|
|
179
|
+
- Username: `admin`
|
|
180
|
+
- Password: `admin`
|
|
181
|
+
|
|
182
|
+
### Stop Graylog
|
|
183
|
+
|
|
184
|
+
```bash
|
|
185
|
+
npm run docker:down
|
|
186
|
+
# or
|
|
187
|
+
docker compose down
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
## Usage Examples
|
|
191
|
+
|
|
192
|
+
### Basic Logging
|
|
193
|
+
|
|
194
|
+
```javascript
|
|
195
|
+
const pino = require('pino');
|
|
196
|
+
const transport = require('@alex-michaud/pino-graylog-transport');
|
|
197
|
+
|
|
198
|
+
// For local development (localhost)
|
|
199
|
+
const logger = pino(transport({
|
|
200
|
+
host: 'localhost',
|
|
201
|
+
port: 12201,
|
|
202
|
+
protocol: 'tcp' // Safe to use TCP for localhost
|
|
203
|
+
}));
|
|
204
|
+
|
|
205
|
+
logger.info('Application started');
|
|
206
|
+
logger.warn('Warning message');
|
|
207
|
+
logger.error('Error message');
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
### Logging with Custom Fields
|
|
211
|
+
|
|
212
|
+
```javascript
|
|
213
|
+
logger.info({
|
|
214
|
+
userId: 123,
|
|
215
|
+
action: 'login',
|
|
216
|
+
ip: '192.168.1.1'
|
|
217
|
+
}, 'User logged in');
|
|
218
|
+
|
|
219
|
+
// In Graylog, these will appear as: _userId, _action, _ip
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### Error Logging with Stack Traces
|
|
223
|
+
|
|
224
|
+
```javascript
|
|
225
|
+
try {
|
|
226
|
+
throw new Error('Something went wrong!');
|
|
227
|
+
} catch (err) {
|
|
228
|
+
logger.error({ err }, 'An error occurred');
|
|
229
|
+
// Stack trace will be sent as full_message in GELF
|
|
230
|
+
}
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### TCP Protocol
|
|
234
|
+
|
|
235
|
+
```javascript
|
|
236
|
+
const logger = pino(await transport({
|
|
237
|
+
host: 'localhost',
|
|
238
|
+
port: 12201,
|
|
239
|
+
protocol: 'tcp' // Use TCP instead of TLS
|
|
240
|
+
}));
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### UDP Protocol
|
|
244
|
+
|
|
245
|
+
```javascript
|
|
246
|
+
const logger = pino(await transport({
|
|
247
|
+
host: 'localhost',
|
|
248
|
+
port: 12201,
|
|
249
|
+
protocol: 'udp' // Use UDP for high-throughput, fire-and-forget logging
|
|
250
|
+
}));
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
**Note:** UDP is connectionless and does not guarantee message delivery. Use it when:
|
|
254
|
+
- You need maximum throughput
|
|
255
|
+
- Occasional message loss is acceptable
|
|
256
|
+
- You're logging to a local Graylog instance
|
|
257
|
+
- Network reliability is high
|
|
258
|
+
|
|
259
|
+
**UDP Limitations:**
|
|
260
|
+
- Messages are limited to **8KB (8192 bytes)** per GELF UDP specification
|
|
261
|
+
- Messages exceeding this size are **rejected** (not sent)
|
|
262
|
+
- For large messages, use TCP or TLS protocols instead
|
|
263
|
+
- No delivery guarantees (fire-and-forget)
|
|
264
|
+
|
|
265
|
+
**Bun Runtime:** When running under [Bun](https://bun.sh/), the UDP transport automatically uses Bun's native `Bun.udpSocket()` API for better performance. Falls back to Node's `dgram` module if unavailable.
|
|
266
|
+
|
|
267
|
+
### Run the Example
|
|
268
|
+
|
|
269
|
+
```bash
|
|
270
|
+
# Start Graylog first
|
|
271
|
+
npm run docker:up
|
|
272
|
+
|
|
273
|
+
# Run the example (after installing dependencies)
|
|
274
|
+
npm install
|
|
275
|
+
node examples/basic.js
|
|
276
|
+
|
|
277
|
+
# View logs at http://localhost:9005
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
## Testing
|
|
281
|
+
|
|
282
|
+
### Install Dependencies
|
|
283
|
+
|
|
284
|
+
```bash
|
|
285
|
+
npm install
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
### Unit Tests
|
|
289
|
+
|
|
290
|
+
Run the library functionality tests (no external dependencies required):
|
|
291
|
+
|
|
292
|
+
```bash
|
|
293
|
+
npm test
|
|
294
|
+
# or
|
|
295
|
+
npm run test:unit
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
### Integration Tests
|
|
299
|
+
|
|
300
|
+
Integration tests require a running Graylog instance:
|
|
301
|
+
|
|
302
|
+
```bash
|
|
303
|
+
# Start Graylog first
|
|
304
|
+
npm run docker:up
|
|
305
|
+
npm run docker:setup
|
|
306
|
+
|
|
307
|
+
# Run integration tests
|
|
308
|
+
npm run test:integration
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
### Run All Tests
|
|
312
|
+
|
|
313
|
+
Run both unit and integration tests:
|
|
314
|
+
|
|
315
|
+
```bash
|
|
316
|
+
npm run test:all
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
### Load Tests with k6
|
|
320
|
+
|
|
321
|
+
Load tests use [k6](https://k6.io) to simulate high-volume logging scenarios.
|
|
322
|
+
|
|
323
|
+
#### Install k6
|
|
324
|
+
|
|
325
|
+
```bash
|
|
326
|
+
# macOS
|
|
327
|
+
brew install k6
|
|
328
|
+
```
|
|
329
|
+
```bash
|
|
330
|
+
# Linux
|
|
331
|
+
sudo gpg -k
|
|
332
|
+
sudo gpg --no-default-keyring --keyring /usr/share/keyrings/k6-archive-keyring.gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys C5AD17C747E3415A3642D57D77C6C491D6AC1D69
|
|
333
|
+
echo "deb [signed-by=/usr/share/keyrings/k6-archive-keyring.gpg] https://dl.k6.io/deb stable main" | sudo tee /etc/apt/sources.list.d/k6.list
|
|
334
|
+
sudo apt-get update
|
|
335
|
+
sudo apt-get install k6
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
```bash
|
|
339
|
+
# Windows
|
|
340
|
+
choco install k6
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
#### Run Load Tests
|
|
344
|
+
|
|
345
|
+
```bash
|
|
346
|
+
# Start Graylog
|
|
347
|
+
npm run docker:up
|
|
348
|
+
|
|
349
|
+
# Setup Graylog inputs (wait for Graylog to be ready first)
|
|
350
|
+
npm run docker:setup
|
|
351
|
+
|
|
352
|
+
# Start the load test server
|
|
353
|
+
npm run start:load-server
|
|
354
|
+
|
|
355
|
+
# In another terminal, run the k6 load test
|
|
356
|
+
npm run test:load
|
|
357
|
+
|
|
358
|
+
# Or run a quick smoke test (20 seconds)
|
|
359
|
+
npm run test:smoke
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
The load test will:
|
|
363
|
+
- Ramp up from 0 to 50 virtual users
|
|
364
|
+
- Send thousands of log messages
|
|
365
|
+
- Measure throughput and latency
|
|
366
|
+
- Verify 95% success rate
|
|
367
|
+
|
|
368
|
+
## Project Structure
|
|
369
|
+
|
|
370
|
+
```
|
|
371
|
+
pino-graylog-transport/
|
|
372
|
+
├── lib/ # Source code
|
|
373
|
+
│ ├── index.ts # Main transport entry point
|
|
374
|
+
│ ├── gelf-formatter.ts # GELF message formatter
|
|
375
|
+
│ └── graylog-transport.ts # TCP/TLS transport
|
|
376
|
+
├── test/ # Tests
|
|
377
|
+
│ ├── unit/ # Unit tests
|
|
378
|
+
│ │ ├── gelf-formatter.test.ts
|
|
379
|
+
│ │ ├── graylog-transport.test.ts
|
|
380
|
+
│ │ └── integration.test.ts
|
|
381
|
+
│ ├── load/ # Load tests
|
|
382
|
+
│ │ ├── load-test.ts # k6 load test script
|
|
383
|
+
│ │ ├── smoke-test.ts # k6 smoke test script
|
|
384
|
+
│ │ └── server.ts # Test server for load testing
|
|
385
|
+
│ └── benchmark/ # Performance benchmarks
|
|
386
|
+
│ ├── benchmark.ts # Microbenchmark (formatting)
|
|
387
|
+
│ ├── comparison-server.ts # Server for pino vs winston test
|
|
388
|
+
│ └── comparison-load-test.ts # k6 comparison load test
|
|
389
|
+
├── examples/ # Usage examples
|
|
390
|
+
│ └── basic.js
|
|
391
|
+
├── docker-compose.yml # Graylog local setup
|
|
392
|
+
└── package.json
|
|
393
|
+
```
|
|
394
|
+
|
|
395
|
+
## Log Level Mapping
|
|
396
|
+
|
|
397
|
+
Pino log levels are automatically converted to Syslog severity levels for Graylog:
|
|
398
|
+
|
|
399
|
+
| Pino Level | Numeric | Syslog Level | Numeric |
|
|
400
|
+
|------------|---------|--------------|---------|
|
|
401
|
+
| fatal | 60 | Critical | 2 |
|
|
402
|
+
| error | 50 | Error | 3 |
|
|
403
|
+
| warn | 40 | Warning | 4 |
|
|
404
|
+
| info | 30 | Informational| 6 |
|
|
405
|
+
| debug | 20 | Debug | 7 |
|
|
406
|
+
| trace | 10 | Debug | 7 |
|
|
407
|
+
|
|
408
|
+
## GELF Message Format
|
|
409
|
+
|
|
410
|
+
The transport converts Pino log objects to GELF format:
|
|
411
|
+
|
|
412
|
+
- `short_message`: The log message
|
|
413
|
+
- `full_message`: Stack trace (if present)
|
|
414
|
+
- `level`: Syslog severity level
|
|
415
|
+
- `timestamp`: Unix timestamp
|
|
416
|
+
- `host`: Hostname
|
|
417
|
+
- `facility`: Application/service name
|
|
418
|
+
- `_*`: Custom fields (all Pino log object properties)
|
|
419
|
+
|
|
420
|
+
## Performance
|
|
421
|
+
|
|
422
|
+
The GELF formatting logic is optimized for speed. Benchmarks were run using [mitata](https://github.com/evanwashere/mitata) to measure the overhead of message transformation (excluding network I/O).
|
|
423
|
+
|
|
424
|
+
### Benchmark Results
|
|
425
|
+
|
|
426
|
+
| Benchmark | Time | Description |
|
|
427
|
+
|-----------|------|-------------|
|
|
428
|
+
| JSON.stringify (Raw) | 614 ns | Baseline - just serialization, no transformation |
|
|
429
|
+
| **pino-graylog-transport** | **1.87 µs** | Our GELF formatter |
|
|
430
|
+
| Manual GELF Construction | 2.21 µs | Simulated naive implementation |
|
|
431
|
+
|
|
432
|
+
### Key Takeaways
|
|
433
|
+
|
|
434
|
+
- ✅ **18% faster** than a naive manual GELF construction approach
|
|
435
|
+
- ✅ **~535,000 messages/second** theoretical formatting throughput (single-threaded)
|
|
436
|
+
- ✅ **Negligible overhead**: The ~1.25 µs formatting overhead is 500-50,000x smaller than typical network latency
|
|
437
|
+
|
|
438
|
+
### Run Benchmarks
|
|
439
|
+
|
|
440
|
+
```bash
|
|
441
|
+
# Run formatting microbenchmark (no network)
|
|
442
|
+
npm run benchmark
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
### Comparison Load Test (pino vs winston)
|
|
446
|
+
|
|
447
|
+
This test compares the real-world performance of `@alex-michaud/pino-graylog-transport` against `winston + winston-log2gelf`:
|
|
448
|
+
|
|
449
|
+
```bash
|
|
450
|
+
# Start Graylog
|
|
451
|
+
npm run docker:up
|
|
452
|
+
npm run docker:setup
|
|
453
|
+
|
|
454
|
+
# Start the comparison server (in one terminal)
|
|
455
|
+
npm run start:comparison-server
|
|
456
|
+
|
|
457
|
+
# Run the comparison load test (in another terminal)
|
|
458
|
+
npm run benchmark:load
|
|
459
|
+
```
|
|
460
|
+
|
|
461
|
+
The test runs three scenarios in parallel:
|
|
462
|
+
- **Baseline**: No logging (measures pure HTTP overhead)
|
|
463
|
+
- **Pino**: Using @alex-michaud/pino-graylog-transport
|
|
464
|
+
- **Winston**: Using winston + winston-log2gelf
|
|
465
|
+
|
|
466
|
+
Compare the `*_duration` metrics to see the logging overhead for each library.
|
|
467
|
+
|
|
468
|
+
## License
|
|
469
|
+
|
|
470
|
+
MIT — see the [LICENSE](./LICENSE) file for details.
|
|
471
|
+
|
|
472
|
+
## Contributing
|
|
473
|
+
|
|
474
|
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
475
|
+
|
|
476
|
+
## Requirements
|
|
477
|
+
|
|
478
|
+
[](https://nodejs.org/)
|
|
479
|
+
|
|
480
|
+
This package requires Node.js >= 22 (declared in package.json `engines`). The CI and release workflows prefer the latest Node LTS. If your local Node version is older, upgrade Node (for example, using nvm):
|
|
481
|
+
|
|
482
|
+
```bash
|
|
483
|
+
# Install nvm (if not present)
|
|
484
|
+
# https://github.com/nvm-sh/nvm#installing-and-updating
|
|
485
|
+
|
|
486
|
+
# Use latest LTS
|
|
487
|
+
nvm install --lts
|
|
488
|
+
nvm use --lts
|
|
489
|
+
```
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface GelfMessage {
|
|
2
|
+
version: '1.1';
|
|
3
|
+
host: string;
|
|
4
|
+
short_message: string;
|
|
5
|
+
full_message?: string;
|
|
6
|
+
timestamp: number;
|
|
7
|
+
level: number;
|
|
8
|
+
[key: string]: unknown;
|
|
9
|
+
}
|
|
10
|
+
export declare const mapPinoLevelToGelf: (pinoLevel: number | undefined) => number;
|
|
11
|
+
export declare function formatGelfMessage(chunk: unknown, hostname: string, facility: string, staticMeta?: Record<string, unknown>): string;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.mapPinoLevelToGelf = void 0;
|
|
4
|
+
exports.formatGelfMessage = formatGelfMessage;
|
|
5
|
+
// OPTIMIZATION: Hoist constant outside the hot path and use Set for O(1) lookup
|
|
6
|
+
const EXCLUDED_FIELDS = new Set([
|
|
7
|
+
'msg',
|
|
8
|
+
'message',
|
|
9
|
+
'level',
|
|
10
|
+
'time',
|
|
11
|
+
'pid',
|
|
12
|
+
'hostname',
|
|
13
|
+
'stack',
|
|
14
|
+
'v',
|
|
15
|
+
'err',
|
|
16
|
+
]);
|
|
17
|
+
const mapPinoLevelToGelf = (pinoLevel) => {
|
|
18
|
+
// Pino levels: 10 trace, 20 debug, 30 info, 40 warn, 50 error, 60 fatal
|
|
19
|
+
// GELF levels: 0 emergency, 1 alert, 2 critical, 3 error, 4 warning, 5 notice, 6 info, 7 debug
|
|
20
|
+
if (!pinoLevel)
|
|
21
|
+
return 6; // default to info
|
|
22
|
+
if (pinoLevel >= 60)
|
|
23
|
+
return 2; // fatal -> critical
|
|
24
|
+
if (pinoLevel >= 50)
|
|
25
|
+
return 3; // error
|
|
26
|
+
if (pinoLevel >= 40)
|
|
27
|
+
return 4; // warning
|
|
28
|
+
if (pinoLevel >= 30)
|
|
29
|
+
return 6; // info
|
|
30
|
+
if (pinoLevel >= 20)
|
|
31
|
+
return 7; // debug
|
|
32
|
+
return 7; // trace -> debug
|
|
33
|
+
};
|
|
34
|
+
exports.mapPinoLevelToGelf = mapPinoLevelToGelf;
|
|
35
|
+
function parseChunk(chunk) {
|
|
36
|
+
// OPTIMIZATION: Check object type first (most common case from Pino)
|
|
37
|
+
if (typeof chunk === 'object' && chunk !== null) {
|
|
38
|
+
return chunk;
|
|
39
|
+
}
|
|
40
|
+
// Handle strings and buffers
|
|
41
|
+
let str = '';
|
|
42
|
+
if (typeof chunk === 'string') {
|
|
43
|
+
str = chunk;
|
|
44
|
+
}
|
|
45
|
+
else if (Buffer.isBuffer(chunk)) {
|
|
46
|
+
str = chunk.toString();
|
|
47
|
+
}
|
|
48
|
+
// OPTIMIZATION: Heuristic check - only try parsing if it looks like JSON
|
|
49
|
+
// This avoids expensive try-catch throws on plain text logs
|
|
50
|
+
const firstChar = str.trim()[0];
|
|
51
|
+
if (firstChar === '{' || firstChar === '[') {
|
|
52
|
+
try {
|
|
53
|
+
return JSON.parse(str);
|
|
54
|
+
}
|
|
55
|
+
catch (_a) {
|
|
56
|
+
// If parse fails despite looking like JSON, fall back to wrapping it
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { msg: str };
|
|
60
|
+
}
|
|
61
|
+
function extractMessage(obj) {
|
|
62
|
+
// OPTIMIZATION: Use explicit type checks (slightly faster than truthiness)
|
|
63
|
+
if (typeof obj.msg === 'string')
|
|
64
|
+
return obj.msg;
|
|
65
|
+
if (typeof obj.message === 'string')
|
|
66
|
+
return obj.message;
|
|
67
|
+
if (typeof obj.short_message === 'string')
|
|
68
|
+
return obj.short_message;
|
|
69
|
+
return JSON.stringify(obj);
|
|
70
|
+
}
|
|
71
|
+
function addStaticMetadata(gelfMessage, staticMeta) {
|
|
72
|
+
// OPTIMIZATION: Use for...in to avoid allocating Object.entries array
|
|
73
|
+
for (const key in staticMeta) {
|
|
74
|
+
const value = staticMeta[key];
|
|
75
|
+
if (value !== undefined && value !== null) {
|
|
76
|
+
// OPTIMIZATION: Use charCodeAt for underscore check (95 is '_')
|
|
77
|
+
const fieldName = key.charCodeAt(0) === 95 ? key : `_${key}`;
|
|
78
|
+
gelfMessage[fieldName] =
|
|
79
|
+
typeof value === 'object' ? JSON.stringify(value) : value;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function addStackTrace(gelfMessage, obj) {
|
|
84
|
+
// Check for direct stack
|
|
85
|
+
if (typeof obj.stack === 'string') {
|
|
86
|
+
gelfMessage.full_message = obj.stack;
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
// Check for nested err.stack using optional chaining
|
|
90
|
+
const err = obj.err;
|
|
91
|
+
if (typeof (err === null || err === void 0 ? void 0 : err.stack) === 'string') {
|
|
92
|
+
gelfMessage.full_message = err.stack;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function addCustomFields(gelfMessage, obj) {
|
|
96
|
+
// OPTIMIZATION: Use for...in loop instead of Object.entries
|
|
97
|
+
for (const key in obj) {
|
|
98
|
+
// OPTIMIZATION: O(1) lookup in Set instead of Array.includes
|
|
99
|
+
if (EXCLUDED_FIELDS.has(key))
|
|
100
|
+
continue;
|
|
101
|
+
const value = obj[key];
|
|
102
|
+
if (value === undefined || value === null)
|
|
103
|
+
continue;
|
|
104
|
+
// OPTIMIZATION: Use charCodeAt for underscore check
|
|
105
|
+
const fieldName = key.charCodeAt(0) === 95 ? key : `_${key}`;
|
|
106
|
+
// Avoid overwriting standard fields or static meta
|
|
107
|
+
if (fieldName in gelfMessage)
|
|
108
|
+
continue;
|
|
109
|
+
// GELF doesn't support nested objects well, stringify them
|
|
110
|
+
gelfMessage[fieldName] =
|
|
111
|
+
typeof value === 'object' ? JSON.stringify(value) : value;
|
|
112
|
+
}
|
|
113
|
+
// Add process info
|
|
114
|
+
if (obj.pid) {
|
|
115
|
+
gelfMessage._pid = obj.pid;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function formatGelfMessage(chunk, hostname, facility, staticMeta = {}) {
|
|
119
|
+
const obj = parseChunk(chunk);
|
|
120
|
+
// Pino adds 'time' field automatically (milliseconds since epoch)
|
|
121
|
+
// We preserve it to maintain accurate event timing even if messages are queued
|
|
122
|
+
// Fallback to current time for non-Pino messages
|
|
123
|
+
// OPTIMIZATION: Use typeof check and multiplication (faster than division)
|
|
124
|
+
const timestamp = typeof obj.time === 'number' ? obj.time * 1e-3 : Date.now() * 1e-3;
|
|
125
|
+
// Build GELF 1.1 message
|
|
126
|
+
const gelfMessage = {
|
|
127
|
+
version: '1.1',
|
|
128
|
+
host: hostname,
|
|
129
|
+
short_message: extractMessage(obj),
|
|
130
|
+
timestamp,
|
|
131
|
+
level: (0, exports.mapPinoLevelToGelf)(obj.level),
|
|
132
|
+
};
|
|
133
|
+
// Add facility as additional field (deprecated in GELF spec, now sent as custom field)
|
|
134
|
+
if (facility) {
|
|
135
|
+
gelfMessage._facility = facility;
|
|
136
|
+
}
|
|
137
|
+
addStaticMetadata(gelfMessage, staticMeta);
|
|
138
|
+
addStackTrace(gelfMessage, obj);
|
|
139
|
+
addCustomFields(gelfMessage, obj);
|
|
140
|
+
return JSON.stringify(gelfMessage);
|
|
141
|
+
}
|