nuxt-server-log 0.1.1
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 +21 -0
- package/README.md +248 -0
- package/dist/module.d.mts +23 -0
- package/dist/module.json +9 -0
- package/dist/module.mjs +43 -0
- package/dist/runtime/server/middleware/logger.d.ts +2 -0
- package/dist/runtime/server/middleware/logger.js +71 -0
- package/dist/runtime/server/plugins/apiInterceptor.d.ts +2 -0
- package/dist/runtime/server/plugins/apiInterceptor.js +43 -0
- package/dist/runtime/server/plugins/errorLogger.d.ts +2 -0
- package/dist/runtime/server/plugins/errorLogger.js +10 -0
- package/dist/runtime/server/tsconfig.json +3 -0
- package/dist/runtime/server/utils/context.d.ts +31 -0
- package/dist/runtime/server/utils/context.js +17 -0
- package/dist/runtime/server/utils/helpers.d.ts +3 -0
- package/dist/runtime/server/utils/helpers.js +35 -0
- package/dist/runtime/server/utils/logger.d.ts +18 -0
- package/dist/runtime/server/utils/logger.js +69 -0
- package/dist/runtime/types.d.ts +11 -0
- package/dist/runtime/types.js +0 -0
- package/dist/types.d.mts +3 -0
- package/package.json +86 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hamid Niakan
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="./assets/logo.png" alt="Nuxt Server Log" width="140" height="140">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
<h1 align="center">Nuxt Server Log</h1>
|
|
6
|
+
|
|
7
|
+
[![npm version][npm-version-src]][npm-version-href]
|
|
8
|
+
[![npm downloads][npm-downloads-src]][npm-downloads-href]
|
|
9
|
+
[![License][license-src]][license-href]
|
|
10
|
+
[![Nuxt][nuxt-src]][nuxt-href]
|
|
11
|
+
|
|
12
|
+
Structured, per-request server-side logging for Nuxt (Nitro). For every incoming
|
|
13
|
+
request it emits a single JSON log line that bundles together:
|
|
14
|
+
|
|
15
|
+
- the **request** itself (method, path, status, duration, client IP, request id),
|
|
16
|
+
- every **outgoing API call** made on the server while handling that request, and
|
|
17
|
+
- every **error** thrown during that request.
|
|
18
|
+
|
|
19
|
+
This works for both your API routes **and your server-rendered (SSR) pages**: when
|
|
20
|
+
a page is rendered on the server, every data-fetching call your app makes during
|
|
21
|
+
that render is captured under the page's request โ so you can see exactly which
|
|
22
|
+
upstream calls contributed to a page, how long each took, and which one made the
|
|
23
|
+
page slow.
|
|
24
|
+
|
|
25
|
+
This gives you one correlated, machine-readable record per request โ ready to ship
|
|
26
|
+
to Elasticsearch, Loki, Datadog, or any log pipeline that ingests JSON.
|
|
27
|
+
|
|
28
|
+
- [โจ Release Notes](/CHANGELOG.md)
|
|
29
|
+
|
|
30
|
+
## Features
|
|
31
|
+
|
|
32
|
+
- ๐ฆ **One JSON log per request** โ request, API calls, and errors correlated by a shared `requestId`.
|
|
33
|
+
- ๐ฅ๏ธ **SSR page insight** โ see every upstream call a server-rendered page made during its render, and exactly how long each took.
|
|
34
|
+
- ๐ **Automatic outbound API tracking** โ server-side `fetch` calls are captured with URL, status, method, and duration.
|
|
35
|
+
- ๐งจ **Automatic error capture** โ unhandled Nitro errors are recorded against the request that caused them.
|
|
36
|
+
- ๐ข **Slow-call warnings** โ configurable thresholds warn on slow responses and slow API calls.
|
|
37
|
+
- ๐ **Query redaction** โ sensitive query params (tokens, passwords, โฆ) are redacted by default.
|
|
38
|
+
- ๐๏ธ **Sampling & filtering** โ log a fraction of traffic and exclude noisy paths.
|
|
39
|
+
- ๐ชช **`X-Request-ID` header** โ added to every response for end-to-end tracing.
|
|
40
|
+
- ๐งท **Crash-safe** โ circular references and BigInt never break logging.
|
|
41
|
+
|
|
42
|
+
## Quick Setup
|
|
43
|
+
|
|
44
|
+
Install the module:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
npx nuxt module add nuxt-server-log
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Or manually:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
npm install nuxt-server-log
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
// nuxt.config.ts
|
|
58
|
+
export default defineNuxtConfig({
|
|
59
|
+
modules: ["nuxt-server-log"],
|
|
60
|
+
});
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
That's it. Requests are now logged to the server console as JSON. โจ
|
|
64
|
+
|
|
65
|
+
## Configuration
|
|
66
|
+
|
|
67
|
+
Configure the module under the `serverLog` key in `nuxt.config.ts`:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
export default defineNuxtConfig({
|
|
71
|
+
modules: ["nuxt-server-log"],
|
|
72
|
+
serverLog: {
|
|
73
|
+
logLevel: "info",
|
|
74
|
+
sampleRate: 1,
|
|
75
|
+
excludePaths: ["/__nuxt_error", "/_nuxt"],
|
|
76
|
+
apiDurationWarning: 1500,
|
|
77
|
+
responseDurationWarning: 3000,
|
|
78
|
+
remoteAddressHeader: "x-real-ip",
|
|
79
|
+
redactQueryKeys: ["token", "password", "secret"],
|
|
80
|
+
traceDepth: 10,
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
| Option | Type | Default | Description |
|
|
86
|
+
| ------------------------- | ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
|
87
|
+
| `enabled` | `boolean` | `true` | Master switch. When `false`, the module registers nothing. |
|
|
88
|
+
| `logLevel` | `"debug" \| "info" \| "warn" \| "error"` | `"info"` | Minimum level for the module's own log messages (e.g. slow-call warnings). The per-request log line is always emitted. |
|
|
89
|
+
| `sampleRate` | `number` | `1` | Fraction of requests to log, from `0` (none) to `1` (all). E.g. `0.1` logs ~10% of requests. |
|
|
90
|
+
| `excludePaths` | `string[]` | `["/__nuxt_error"]` | Requests whose path **starts with** any of these are not logged. |
|
|
91
|
+
| `apiDurationWarning` | `number` | `1500` | Emit a `warn` when a captured API call exceeds this many milliseconds. |
|
|
92
|
+
| `responseDurationWarning` | `number` | `3000` | Emit a `warn` when total request duration exceeds this many milliseconds. |
|
|
93
|
+
| `remoteAddressHeader` | `string` | `undefined` | Header to read the client IP from (e.g. behind a proxy). Falls back to `X-Forwarded-For` / socket address. |
|
|
94
|
+
| `redactQueryKeys` | `string[]` | `["token", "password", "secret", "apiKey", "api_key", "auth", "authorization", "access_token", "refresh_token"]` | Query-string keys whose values are replaced with `[REDACTED]` in logs (case-insensitive). |
|
|
95
|
+
| `traceDepth` | `number` | `10` | Maximum number of stack-trace frames recorded per error. |
|
|
96
|
+
|
|
97
|
+
> [!NOTE]
|
|
98
|
+
> `remoteAddressHeader` and `X-Forwarded-For` are client-controllable and can be
|
|
99
|
+
> spoofed unless your app sits behind a trusted proxy that overwrites them.
|
|
100
|
+
|
|
101
|
+
## How it works
|
|
102
|
+
|
|
103
|
+
The module registers three pieces of Nitro runtime:
|
|
104
|
+
|
|
105
|
+
1. **A server middleware** that opens a per-request context (via `AsyncLocalStorage`),
|
|
106
|
+
assigns a `requestId`, sets the `X-Request-ID` response header, and writes the
|
|
107
|
+
final JSON log line once the response finishes (or the connection closes).
|
|
108
|
+
2. **A `fetch` interceptor** that wraps `globalThis.fetch`, so any server-side
|
|
109
|
+
API call is recorded into the current request's context.
|
|
110
|
+
3. **An error hook** that records unhandled Nitro errors against the active request.
|
|
111
|
+
|
|
112
|
+
Because everything is tied together through the request context, a single log line
|
|
113
|
+
tells the full story of what happened during that request.
|
|
114
|
+
|
|
115
|
+
## Example output
|
|
116
|
+
|
|
117
|
+
A **server-rendered page** (`GET /`) whose render fetched data from four upstream
|
|
118
|
+
APIs. The whole page took ~2s, and you can immediately see that one categories
|
|
119
|
+
call (1259ms) dominated the render time:
|
|
120
|
+
|
|
121
|
+
```json
|
|
122
|
+
{
|
|
123
|
+
"@timestamp": "2026-06-19T11:32:03.271Z",
|
|
124
|
+
"requestId": "2ce40ef3-a8d0-4642-82d7-e15bcbf418dc",
|
|
125
|
+
"userAgent": "unknown",
|
|
126
|
+
"statusCode": 200,
|
|
127
|
+
"method": "GET",
|
|
128
|
+
"path": "/",
|
|
129
|
+
"query": "",
|
|
130
|
+
"remoteAddress": "::ffff:127.0.0.1",
|
|
131
|
+
"duration": 2012,
|
|
132
|
+
"apiCalls": [
|
|
133
|
+
{
|
|
134
|
+
"@timestamp": "2026-06-19T11:32:01.482Z",
|
|
135
|
+
"statusCode": 200,
|
|
136
|
+
"url": "https://api.example.com/v1/categories/",
|
|
137
|
+
"method": "GET",
|
|
138
|
+
"duration": 1259
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
"@timestamp": "2026-06-19T11:32:02.801Z",
|
|
142
|
+
"statusCode": 200,
|
|
143
|
+
"url": "https://api.example.com/v1/sliders/",
|
|
144
|
+
"method": "GET",
|
|
145
|
+
"duration": 91
|
|
146
|
+
}
|
|
147
|
+
],
|
|
148
|
+
"errors": []
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
An API route that made one upstream API call:
|
|
153
|
+
|
|
154
|
+
```json
|
|
155
|
+
{
|
|
156
|
+
"@timestamp": "2026-06-19T11:34:41.959Z",
|
|
157
|
+
"requestId": "ffbc6274-a7f0-40d6-86fe-1e4154ab1f1b",
|
|
158
|
+
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
|
159
|
+
"statusCode": 200,
|
|
160
|
+
"method": "GET",
|
|
161
|
+
"path": "/api/products",
|
|
162
|
+
"query": "?token=%5BREDACTED%5D&page=2",
|
|
163
|
+
"remoteAddress": "203.0.113.7",
|
|
164
|
+
"duration": 142,
|
|
165
|
+
"apiCalls": [
|
|
166
|
+
{
|
|
167
|
+
"@timestamp": "2026-06-19T11:34:41.820Z",
|
|
168
|
+
"statusCode": 200,
|
|
169
|
+
"url": "https://api.example.com/products?page=2",
|
|
170
|
+
"method": "GET",
|
|
171
|
+
"duration": 98
|
|
172
|
+
}
|
|
173
|
+
],
|
|
174
|
+
"errors": []
|
|
175
|
+
}
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
A request where an upstream call failed and an error was thrown:
|
|
179
|
+
|
|
180
|
+
```json
|
|
181
|
+
{
|
|
182
|
+
"@timestamp": "2026-06-19T11:35:02.114Z",
|
|
183
|
+
"requestId": "a18d2f90-1c4e-4b2a-9a77-2f0b5c9d11aa",
|
|
184
|
+
"userAgent": "node",
|
|
185
|
+
"statusCode": 500,
|
|
186
|
+
"method": "POST",
|
|
187
|
+
"path": "/api/checkout",
|
|
188
|
+
"query": "",
|
|
189
|
+
"remoteAddress": "203.0.113.7",
|
|
190
|
+
"duration": 233,
|
|
191
|
+
"apiCalls": [
|
|
192
|
+
{
|
|
193
|
+
"@timestamp": "2026-06-19T11:35:02.020Z",
|
|
194
|
+
"statusCode": 503,
|
|
195
|
+
"url": "https://payments.example.com/charge",
|
|
196
|
+
"method": "POST",
|
|
197
|
+
"duration": 180,
|
|
198
|
+
"error": "HTTP Error 503: Service Unavailable"
|
|
199
|
+
}
|
|
200
|
+
],
|
|
201
|
+
"errors": [
|
|
202
|
+
{
|
|
203
|
+
"@timestamp": "2026-06-19T11:35:02.110Z",
|
|
204
|
+
"error": "Payment provider unavailable",
|
|
205
|
+
"type": "Error",
|
|
206
|
+
"trace": [
|
|
207
|
+
"at chargeCard (server/api/checkout.post.ts:24:11)",
|
|
208
|
+
"at handler (server/api/checkout.post.ts:10:3)"
|
|
209
|
+
],
|
|
210
|
+
"action": "unhandled"
|
|
211
|
+
}
|
|
212
|
+
]
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
In addition to the per-request line, slow requests/calls produce standalone `warn`
|
|
217
|
+
entries, e.g.:
|
|
218
|
+
|
|
219
|
+
```json
|
|
220
|
+
{
|
|
221
|
+
"@timestamp": "โฆ",
|
|
222
|
+
"level": "warn",
|
|
223
|
+
"message": "Slow API call detected",
|
|
224
|
+
"requestId": "โฆ",
|
|
225
|
+
"url": "https://api.example.com/products",
|
|
226
|
+
"duration": 1820
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## Contribution
|
|
231
|
+
|
|
232
|
+
Contributions are welcome! The flow is the standard GitHub one: fork the repo,
|
|
233
|
+
create a branch, and open a pull request.
|
|
234
|
+
|
|
235
|
+
## License
|
|
236
|
+
|
|
237
|
+
[MIT](./LICENSE)
|
|
238
|
+
|
|
239
|
+
<!-- Badges -->
|
|
240
|
+
|
|
241
|
+
[npm-version-src]: https://img.shields.io/npm/v/nuxt-server-log/latest.svg?style=flat&colorA=020420&colorB=00DC82
|
|
242
|
+
[npm-version-href]: https://npmjs.com/package/nuxt-server-log
|
|
243
|
+
[npm-downloads-src]: https://img.shields.io/npm/dm/nuxt-server-log.svg?style=flat&colorA=020420&colorB=00DC82
|
|
244
|
+
[npm-downloads-href]: https://npm.chart.dev/nuxt-server-log
|
|
245
|
+
[license-src]: https://img.shields.io/npm/l/nuxt-server-log.svg?style=flat&colorA=020420&colorB=00DC82
|
|
246
|
+
[license-href]: https://npmjs.com/package/nuxt-server-log
|
|
247
|
+
[nuxt-src]: https://img.shields.io/badge/Nuxt-020420?logo=nuxt
|
|
248
|
+
[nuxt-href]: https://nuxt.com
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import * as _nuxt_schema from '@nuxt/schema';
|
|
2
|
+
import { LoggerRuntimeConfig } from '../dist/runtime/types.js';
|
|
3
|
+
|
|
4
|
+
interface ModuleOptions {
|
|
5
|
+
enabled?: boolean;
|
|
6
|
+
logLevel?: "debug" | "info" | "warn" | "error";
|
|
7
|
+
sampleRate?: number;
|
|
8
|
+
excludePaths?: string[];
|
|
9
|
+
apiDurationWarning?: number;
|
|
10
|
+
responseDurationWarning?: number;
|
|
11
|
+
remoteAddressHeader?: string;
|
|
12
|
+
redactQueryKeys?: string[];
|
|
13
|
+
traceDepth?: number;
|
|
14
|
+
}
|
|
15
|
+
declare module "@nuxt/schema" {
|
|
16
|
+
interface RuntimeConfig {
|
|
17
|
+
logger: LoggerRuntimeConfig;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
declare const _default: _nuxt_schema.NuxtModule<ModuleOptions, ModuleOptions, false>;
|
|
21
|
+
|
|
22
|
+
export { _default as default };
|
|
23
|
+
export type { ModuleOptions };
|
package/dist/module.json
ADDED
package/dist/module.mjs
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { defineNuxtModule, createResolver, addServerHandler, addServerPlugin } from 'nuxt/kit';
|
|
2
|
+
|
|
3
|
+
const module$1 = defineNuxtModule({
|
|
4
|
+
meta: {
|
|
5
|
+
name: "nuxt-server-log",
|
|
6
|
+
configKey: "serverLog"
|
|
7
|
+
},
|
|
8
|
+
defaults: {
|
|
9
|
+
enabled: true,
|
|
10
|
+
logLevel: "info",
|
|
11
|
+
sampleRate: 1,
|
|
12
|
+
excludePaths: ["/__nuxt_error"],
|
|
13
|
+
apiDurationWarning: 1500,
|
|
14
|
+
responseDurationWarning: 3e3,
|
|
15
|
+
redactQueryKeys: [
|
|
16
|
+
"token",
|
|
17
|
+
"password",
|
|
18
|
+
"secret",
|
|
19
|
+
"apiKey",
|
|
20
|
+
"api_key",
|
|
21
|
+
"auth",
|
|
22
|
+
"authorization",
|
|
23
|
+
"access_token",
|
|
24
|
+
"refresh_token"
|
|
25
|
+
],
|
|
26
|
+
traceDepth: 10
|
|
27
|
+
},
|
|
28
|
+
setup(options, nuxt) {
|
|
29
|
+
if (!options.enabled) return;
|
|
30
|
+
const resolver = createResolver(import.meta.url);
|
|
31
|
+
nuxt.options.runtimeConfig.logger = { ...options };
|
|
32
|
+
addServerHandler({
|
|
33
|
+
middleware: true,
|
|
34
|
+
handler: resolver.resolve("./runtime/server/middleware/logger")
|
|
35
|
+
});
|
|
36
|
+
addServerPlugin(
|
|
37
|
+
resolver.resolve("./runtime/server/plugins/apiInterceptor")
|
|
38
|
+
);
|
|
39
|
+
addServerPlugin(resolver.resolve("./runtime/server/plugins/errorLogger"));
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export { module$1 as default };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import {
|
|
2
|
+
defineEventHandler,
|
|
3
|
+
getRequestURL,
|
|
4
|
+
getHeaders,
|
|
5
|
+
getRequestIP
|
|
6
|
+
} from "h3";
|
|
7
|
+
import { requestContext } from "../utils/context.js";
|
|
8
|
+
import { logger } from "../utils/logger.js";
|
|
9
|
+
import { redactQueryString } from "../utils/helpers.js";
|
|
10
|
+
import { randomUUID } from "node:crypto";
|
|
11
|
+
import { useRuntimeConfig } from "#imports";
|
|
12
|
+
export default defineEventHandler(async (event) => {
|
|
13
|
+
const config = useRuntimeConfig().logger;
|
|
14
|
+
const url = getRequestURL(event);
|
|
15
|
+
if (config.excludePaths?.some((path) => url.pathname.startsWith(path)))
|
|
16
|
+
return;
|
|
17
|
+
if (Math.random() > (config.sampleRate ?? 1)) return;
|
|
18
|
+
const requestId = getHeaders(event)["x-request-id"] || randomUUID();
|
|
19
|
+
const startTime = performance.now();
|
|
20
|
+
const ctx = {
|
|
21
|
+
requestId,
|
|
22
|
+
startTime,
|
|
23
|
+
event,
|
|
24
|
+
apiCalls: [],
|
|
25
|
+
errors: [],
|
|
26
|
+
metaData: {}
|
|
27
|
+
};
|
|
28
|
+
requestContext.enterWith(ctx);
|
|
29
|
+
event.node.res.setHeader("X-Request-ID", requestId);
|
|
30
|
+
let logged = false;
|
|
31
|
+
const writeRequestLog = () => {
|
|
32
|
+
if (logged) return;
|
|
33
|
+
logged = true;
|
|
34
|
+
const duration = performance.now() - startTime;
|
|
35
|
+
const headers = getHeaders(event);
|
|
36
|
+
const logEntry = {
|
|
37
|
+
"@timestamp": (/* @__PURE__ */ new Date()).toISOString(),
|
|
38
|
+
requestId,
|
|
39
|
+
userAgent: headers["user-agent"] || "unknown",
|
|
40
|
+
statusCode: event.node.res.statusCode,
|
|
41
|
+
method: event.method,
|
|
42
|
+
path: url.pathname,
|
|
43
|
+
query: redactQueryString(url.search, config.redactQueryKeys ?? []),
|
|
44
|
+
// `xForwardedFor` trusts the X-Forwarded-For header, which is spoofable
|
|
45
|
+
// unless requests pass through a trusted proxy. Set `remoteAddressHeader`
|
|
46
|
+
// to read the client IP from a header your proxy controls instead.
|
|
47
|
+
remoteAddress: (config.remoteAddressHeader ? headers[config.remoteAddressHeader] : void 0) || getRequestIP(event, { xForwardedFor: true }),
|
|
48
|
+
duration: Math.round(duration),
|
|
49
|
+
apiCalls: ctx.apiCalls,
|
|
50
|
+
errors: ctx.errors,
|
|
51
|
+
metadata: Object.keys(ctx.metaData).length > 0 ? ctx.metaData : void 0
|
|
52
|
+
};
|
|
53
|
+
if (duration > config.responseDurationWarning) {
|
|
54
|
+
logger.warn("Slow response detected", {
|
|
55
|
+
duration: Math.round(duration),
|
|
56
|
+
path: url.pathname
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
ctx.apiCalls.forEach((call) => {
|
|
60
|
+
if (call.duration > config.apiDurationWarning) {
|
|
61
|
+
logger.warn("Slow API call detected", {
|
|
62
|
+
url: call.url,
|
|
63
|
+
duration: Math.round(call.duration)
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
logger.logRequest(logEntry);
|
|
68
|
+
};
|
|
69
|
+
event.node.res.on("finish", writeRequestLog);
|
|
70
|
+
event.node.res.on("close", writeRequestLog);
|
|
71
|
+
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { addApiCall } from "../utils/context.js";
|
|
2
|
+
import { defineNitroPlugin } from "nitropack/runtime";
|
|
3
|
+
const FETCH_PATCHED = Symbol.for("nuxt-server-log:fetch-patched");
|
|
4
|
+
export default defineNitroPlugin(() => {
|
|
5
|
+
const globalScope = globalThis;
|
|
6
|
+
if (globalScope[FETCH_PATCHED]) return;
|
|
7
|
+
globalScope[FETCH_PATCHED] = true;
|
|
8
|
+
const originalNativeFetch = globalThis.fetch;
|
|
9
|
+
globalThis.fetch = new Proxy(originalNativeFetch, {
|
|
10
|
+
apply: async (target, thisArg, args) => {
|
|
11
|
+
const [input, init = {}] = args;
|
|
12
|
+
const startTime = performance.now();
|
|
13
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
14
|
+
const method = init.method || (typeof input === "object" && "method" in input ? input.method : "GET");
|
|
15
|
+
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
|
16
|
+
let status = null;
|
|
17
|
+
let error;
|
|
18
|
+
try {
|
|
19
|
+
const response = await Reflect.apply(target, thisArg, args);
|
|
20
|
+
status = response.status;
|
|
21
|
+
if (!response.ok)
|
|
22
|
+
error = `HTTP Error ${status}: ${response.statusText || "Unknown Error"}`;
|
|
23
|
+
return response;
|
|
24
|
+
} catch (err) {
|
|
25
|
+
if (err instanceof Error) error = err.message;
|
|
26
|
+
else error = String(err);
|
|
27
|
+
status = null;
|
|
28
|
+
throw err;
|
|
29
|
+
} finally {
|
|
30
|
+
const duration = performance.now() - startTime;
|
|
31
|
+
const apiCall = {
|
|
32
|
+
"@timestamp": timestamp,
|
|
33
|
+
statusCode: status,
|
|
34
|
+
url,
|
|
35
|
+
method: method.toUpperCase(),
|
|
36
|
+
duration: Math.round(duration),
|
|
37
|
+
error
|
|
38
|
+
};
|
|
39
|
+
addApiCall(apiCall);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { logger } from "../utils/logger.js";
|
|
2
|
+
import { defineNitroPlugin } from "nitropack/runtime";
|
|
3
|
+
export default defineNitroPlugin((nitroApp) => {
|
|
4
|
+
nitroApp.hooks.hook("error", (error, { event }) => {
|
|
5
|
+
logger.error("Nitro Error!", error, {
|
|
6
|
+
path: event?.path,
|
|
7
|
+
method: event?.method
|
|
8
|
+
});
|
|
9
|
+
});
|
|
10
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import type { H3Event } from "h3";
|
|
3
|
+
export interface ApiCall {
|
|
4
|
+
"@timestamp": string;
|
|
5
|
+
statusCode: number | null;
|
|
6
|
+
url: string;
|
|
7
|
+
method: string;
|
|
8
|
+
duration: number;
|
|
9
|
+
error?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface ErrorLog {
|
|
12
|
+
"@timestamp": string;
|
|
13
|
+
error: string;
|
|
14
|
+
type: string;
|
|
15
|
+
trace: string[];
|
|
16
|
+
action: "unhandled" | "caught" | "fatal";
|
|
17
|
+
context?: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
export interface RequestContext {
|
|
20
|
+
requestId: string;
|
|
21
|
+
startTime: number;
|
|
22
|
+
event: H3Event;
|
|
23
|
+
apiCalls: ApiCall[];
|
|
24
|
+
errors: ErrorLog[];
|
|
25
|
+
metaData: Record<string, unknown>;
|
|
26
|
+
}
|
|
27
|
+
export declare const requestContext: AsyncLocalStorage<RequestContext>;
|
|
28
|
+
export declare function getRequestContext(): RequestContext | undefined;
|
|
29
|
+
export declare function addApiCall(call: ApiCall): void;
|
|
30
|
+
export declare function addError(error: ErrorLog): void;
|
|
31
|
+
export declare function setMetaData(key: string, value: unknown): void;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
export const requestContext = new AsyncLocalStorage();
|
|
3
|
+
export function getRequestContext() {
|
|
4
|
+
return requestContext.getStore();
|
|
5
|
+
}
|
|
6
|
+
export function addApiCall(call) {
|
|
7
|
+
const ctx = getRequestContext();
|
|
8
|
+
ctx?.apiCalls.push(call);
|
|
9
|
+
}
|
|
10
|
+
export function addError(error) {
|
|
11
|
+
const ctx = getRequestContext();
|
|
12
|
+
ctx?.errors.push(error);
|
|
13
|
+
}
|
|
14
|
+
export function setMetaData(key, value) {
|
|
15
|
+
const ctx = getRequestContext();
|
|
16
|
+
if (ctx) ctx.metaData[key] = value;
|
|
17
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export function parseStackTrace(stack, depth = 10) {
|
|
2
|
+
if (!stack) return [];
|
|
3
|
+
return stack.split("\n").map((line) => line.trim()).filter((line) => line.startsWith("at ")).slice(0, depth);
|
|
4
|
+
}
|
|
5
|
+
export function safeStringify(value) {
|
|
6
|
+
const seen = /* @__PURE__ */ new WeakSet();
|
|
7
|
+
try {
|
|
8
|
+
return JSON.stringify(value, (_key, val) => {
|
|
9
|
+
if (typeof val === "bigint") return val.toString();
|
|
10
|
+
if (typeof val === "object" && val !== null) {
|
|
11
|
+
if (seen.has(val)) return "[Circular]";
|
|
12
|
+
seen.add(val);
|
|
13
|
+
}
|
|
14
|
+
return val;
|
|
15
|
+
});
|
|
16
|
+
} catch (err) {
|
|
17
|
+
return JSON.stringify({
|
|
18
|
+
logError: "Failed to serialize log entry",
|
|
19
|
+
reason: err instanceof Error ? err.message : String(err)
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function redactQueryString(search, redactKeys) {
|
|
24
|
+
if (!search || redactKeys.length === 0) return search;
|
|
25
|
+
const params = new URLSearchParams(search);
|
|
26
|
+
const lowered = redactKeys.map((key) => key.toLowerCase());
|
|
27
|
+
let changed = false;
|
|
28
|
+
for (const key of [...params.keys()]) {
|
|
29
|
+
if (lowered.includes(key.toLowerCase())) {
|
|
30
|
+
params.set(key, "[REDACTED]");
|
|
31
|
+
changed = true;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return changed ? `?${params.toString()}` : search;
|
|
35
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ErrorLog } from "./context.js";
|
|
2
|
+
declare class Logger {
|
|
3
|
+
private static instance;
|
|
4
|
+
private logLevel?;
|
|
5
|
+
private levels;
|
|
6
|
+
private constructor();
|
|
7
|
+
static getInstance(): Logger;
|
|
8
|
+
private resolveLogLevel;
|
|
9
|
+
private shouldLog;
|
|
10
|
+
private formatLog;
|
|
11
|
+
debug(message: string, data?: Record<string, unknown>): void;
|
|
12
|
+
info(message: string, data?: Record<string, unknown>): void;
|
|
13
|
+
warn(message: string, data?: Record<string, unknown>): void;
|
|
14
|
+
error(message: string, error?: Error, data?: Record<string, unknown>, action?: ErrorLog["action"]): void;
|
|
15
|
+
logRequest(data: Record<string, unknown>): void;
|
|
16
|
+
}
|
|
17
|
+
export declare const logger: Logger;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { getRequestContext } from "./context.js";
|
|
2
|
+
import { parseStackTrace, safeStringify } from "./helpers.js";
|
|
3
|
+
import { useRuntimeConfig } from "#imports";
|
|
4
|
+
class Logger {
|
|
5
|
+
static instance;
|
|
6
|
+
logLevel;
|
|
7
|
+
levels = { debug: 0, info: 1, warn: 2, error: 3 };
|
|
8
|
+
constructor() {
|
|
9
|
+
}
|
|
10
|
+
static getInstance() {
|
|
11
|
+
if (!Logger.instance) Logger.instance = new Logger();
|
|
12
|
+
return Logger.instance;
|
|
13
|
+
}
|
|
14
|
+
resolveLogLevel() {
|
|
15
|
+
if (this.logLevel === void 0) {
|
|
16
|
+
const level = useRuntimeConfig().logger?.logLevel;
|
|
17
|
+
this.logLevel = level !== void 0 ? this.levels[level] ?? 1 : 1;
|
|
18
|
+
}
|
|
19
|
+
return this.logLevel;
|
|
20
|
+
}
|
|
21
|
+
shouldLog(level) {
|
|
22
|
+
return this.levels[level] >= this.resolveLogLevel();
|
|
23
|
+
}
|
|
24
|
+
formatLog(level, message, data) {
|
|
25
|
+
const ctx = getRequestContext();
|
|
26
|
+
const baseLog = {
|
|
27
|
+
"@timestamp": (/* @__PURE__ */ new Date()).toISOString(),
|
|
28
|
+
level,
|
|
29
|
+
message,
|
|
30
|
+
requestId: ctx?.requestId,
|
|
31
|
+
...data
|
|
32
|
+
};
|
|
33
|
+
return safeStringify(baseLog);
|
|
34
|
+
}
|
|
35
|
+
debug(message, data) {
|
|
36
|
+
if (this.shouldLog("debug"))
|
|
37
|
+
console.log(this.formatLog("debug", message, data));
|
|
38
|
+
}
|
|
39
|
+
info(message, data) {
|
|
40
|
+
if (this.shouldLog("info"))
|
|
41
|
+
console.log(this.formatLog("info", message, data));
|
|
42
|
+
}
|
|
43
|
+
warn(message, data) {
|
|
44
|
+
if (this.shouldLog("warn"))
|
|
45
|
+
console.warn(this.formatLog("warn", message, data));
|
|
46
|
+
}
|
|
47
|
+
error(message, error, data, action = "unhandled") {
|
|
48
|
+
if (this.shouldLog("error")) {
|
|
49
|
+
const config = useRuntimeConfig().logger;
|
|
50
|
+
const errorLog = {
|
|
51
|
+
"@timestamp": (/* @__PURE__ */ new Date()).toISOString(),
|
|
52
|
+
error: error?.message || message,
|
|
53
|
+
type: error?.name || "Error",
|
|
54
|
+
trace: parseStackTrace(error?.stack, config?.traceDepth),
|
|
55
|
+
action,
|
|
56
|
+
context: error?.cause ? { cause: error.cause } : void 0
|
|
57
|
+
};
|
|
58
|
+
const ctx = getRequestContext();
|
|
59
|
+
if (ctx) ctx.errors.push(errorLog);
|
|
60
|
+
console.error(
|
|
61
|
+
this.formatLog("error", message, { error: errorLog, data })
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
logRequest(data) {
|
|
66
|
+
console.log(safeStringify(data));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export const logger = Logger.getInstance();
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface LoggerRuntimeConfig {
|
|
2
|
+
enabled: boolean;
|
|
3
|
+
logLevel: "debug" | "info" | "warn" | "error";
|
|
4
|
+
sampleRate: number;
|
|
5
|
+
excludePaths: string[];
|
|
6
|
+
apiDurationWarning: number;
|
|
7
|
+
responseDurationWarning: number;
|
|
8
|
+
remoteAddressHeader?: string;
|
|
9
|
+
redactQueryKeys: string[];
|
|
10
|
+
traceDepth: number;
|
|
11
|
+
}
|
|
File without changes
|
package/dist/types.d.mts
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "nuxt-server-log",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "A Nuxt module for structured server-side logging",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/Hamid-Niakan/nuxt-server-log.git"
|
|
8
|
+
},
|
|
9
|
+
"packageManager": "npm@10.9.2",
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": "^20.19.0 || >=22.12.0"
|
|
12
|
+
},
|
|
13
|
+
"author": {
|
|
14
|
+
"email": "hniakan@gmail.com",
|
|
15
|
+
"name": "Hamid Niakan"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"nuxt",
|
|
19
|
+
"nuxt-module",
|
|
20
|
+
"logging",
|
|
21
|
+
"logger",
|
|
22
|
+
"structured-logging",
|
|
23
|
+
"request-logging",
|
|
24
|
+
"observability",
|
|
25
|
+
"nitro",
|
|
26
|
+
"SSR",
|
|
27
|
+
"server"
|
|
28
|
+
],
|
|
29
|
+
"homepage": "https://github.com/Hamid-Niakan/nuxt-server-log#readme",
|
|
30
|
+
"bugs": {
|
|
31
|
+
"url": "https://github.com/Hamid-Niakan/nuxt-server-log/issues"
|
|
32
|
+
},
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"type": "module",
|
|
35
|
+
"exports": {
|
|
36
|
+
".": {
|
|
37
|
+
"types": "./dist/types.d.mts",
|
|
38
|
+
"import": "./dist/module.mjs"
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
"main": "./dist/module.mjs",
|
|
42
|
+
"typesVersions": {
|
|
43
|
+
"*": {
|
|
44
|
+
".": [
|
|
45
|
+
"./dist/types.d.mts"
|
|
46
|
+
]
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"files": [
|
|
50
|
+
"dist"
|
|
51
|
+
],
|
|
52
|
+
"workspaces": [
|
|
53
|
+
"playground"
|
|
54
|
+
],
|
|
55
|
+
"scripts": {
|
|
56
|
+
"prepack": "nuxt-module-build build",
|
|
57
|
+
"dev": "npm run dev:prepare && nuxt dev playground",
|
|
58
|
+
"dev:build": "nuxt build playground",
|
|
59
|
+
"dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxt prepare playground",
|
|
60
|
+
"release": "npm run lint && npm run test && npm run prepack && changelogen --release && npm publish && git push --follow-tags",
|
|
61
|
+
"lint": "eslint .",
|
|
62
|
+
"test": "vitest run",
|
|
63
|
+
"test:watch": "vitest watch",
|
|
64
|
+
"test:types": "vue-tsc --noEmit && cd playground && vue-tsc --noEmit"
|
|
65
|
+
},
|
|
66
|
+
"peerDependencies": {
|
|
67
|
+
"nuxt": "^4.0.0"
|
|
68
|
+
},
|
|
69
|
+
"dependencies": {
|
|
70
|
+
"@nuxt/kit": "^4.4.5"
|
|
71
|
+
},
|
|
72
|
+
"devDependencies": {
|
|
73
|
+
"@nuxt/devtools": "^3.2.4",
|
|
74
|
+
"@nuxt/eslint-config": "^1.15.2",
|
|
75
|
+
"@nuxt/module-builder": "^1.0.2",
|
|
76
|
+
"@nuxt/schema": "^4.4.5",
|
|
77
|
+
"@nuxt/test-utils": "^4.0.3",
|
|
78
|
+
"@types/node": "latest",
|
|
79
|
+
"changelogen": "^0.6.2",
|
|
80
|
+
"eslint": "^10.3.0",
|
|
81
|
+
"nuxt": "^4.4.5",
|
|
82
|
+
"typescript": "~6.0.3",
|
|
83
|
+
"vitest": "^4.1.5",
|
|
84
|
+
"vue-tsc": "^3.2.8"
|
|
85
|
+
}
|
|
86
|
+
}
|