@cooperco/nuxt-layer-base 1.0.4 → 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 +342 -201
- package/app/composables/useLogger.ts +28 -0
- package/app/composables/useLoggly.ts +5 -27
- package/app/plugins/loggly.client.ts +42 -5
- package/eslint.config.mjs +7 -2
- package/nuxt.config.ts +1 -7
- package/package.json +7 -7
- package/server/api/log.post.ts +58 -0
- package/server/api/loggly.post.ts +1 -58
- package/server/plugins/loggly.ts +49 -5
package/README.md
CHANGED
|
@@ -2,81 +2,56 @@
|
|
|
2
2
|
|
|
3
3
|
A foundational layer that provides essential configuration and tooling for Nuxt 4 projects.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
Compatibility Date: 2025-07-15
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
- ESLint configuration with stylistic rules for Vue templates
|
|
9
|
-
- Test utilities integration
|
|
10
|
-
- Internationalization (i18n) via `@nuxtjs/i18n`
|
|
11
|
-
- Nuxt DevTools enabled
|
|
12
|
-
- CI/CD workflows for code quality checks
|
|
7
|
+
—
|
|
13
8
|
|
|
14
|
-
##
|
|
9
|
+
## About Nuxt Layers
|
|
15
10
|
|
|
16
|
-
|
|
17
|
-
# Install dependencies
|
|
18
|
-
npm install
|
|
19
|
-
|
|
20
|
-
# Start development server
|
|
21
|
-
npm run dev
|
|
22
|
-
|
|
23
|
-
# Run ESLint
|
|
24
|
-
npm run lint
|
|
25
|
-
|
|
26
|
-
# Fix linting issues automatically
|
|
27
|
-
npm run lint:fix
|
|
28
|
-
|
|
29
|
-
# Run TypeScript type checking
|
|
30
|
-
npm run typecheck
|
|
31
|
-
```
|
|
11
|
+
Nuxt Layers are a way to share configuration, modules, and app/server files across multiple Nuxt projects. An app “extends” a layer, and Nuxt will merge the layer’s modules, runtime configuration, and directory contents (like `app/`, `server/`, and `plugins/`) with the app’s own files. This lets teams centralize common tooling and conventions while each app remains free to add its own code.
|
|
32
12
|
|
|
33
|
-
|
|
13
|
+
Learn more in the official Nuxt Layers documentation: https://nuxt.com/docs/guide/going-further/layers
|
|
34
14
|
|
|
35
|
-
|
|
15
|
+
## For app developers: Using this layer
|
|
36
16
|
|
|
37
|
-
|
|
38
|
-
- **Strict TypeScript:** Enforces type safety throughout your projects
|
|
39
|
-
- **ESLint Integration:** Pre-configured with stylistic rules for Vue templates
|
|
40
|
-
- Custom ESLint rules for code style consistency
|
|
41
|
-
- Vue template formatting rules
|
|
42
|
-
- **Testing Utilities:** Built-in test utils for comprehensive testing
|
|
17
|
+
### What this layer provides
|
|
43
18
|
|
|
44
|
-
|
|
19
|
+
- TypeScript with strict mode enabled
|
|
20
|
+
- ESLint via `@nuxt/eslint` with stylistic Vue template rules
|
|
21
|
+
- Internationalization (i18n) via `@nuxtjs/i18n`
|
|
22
|
+
- Nuxt DevTools enabled in development
|
|
23
|
+
- Optional error and event logging to Loggly (client and server), disabled by default
|
|
45
24
|
|
|
46
|
-
|
|
25
|
+
### Install in your app
|
|
47
26
|
|
|
48
|
-
|
|
49
|
-
- Nuxt Test Utils module (`@nuxt/test-utils`)
|
|
50
|
-
- Nuxt i18n module (`@nuxtjs/i18n`)
|
|
51
|
-
- Nuxt DevTools enabled
|
|
52
|
-
- TypeScript configuration with strict mode
|
|
53
|
-
- Custom ESLint configuration with rules for:
|
|
54
|
-
- JavaScript stylistic preferences
|
|
55
|
-
- TypeScript unused variables handling
|
|
56
|
-
- Vue template formatting
|
|
27
|
+
1) Add the layer to your project as a dev dependency
|
|
57
28
|
|
|
58
|
-
|
|
29
|
+
```bash
|
|
30
|
+
# npm
|
|
31
|
+
npm i -D @cooperco/nuxt-layer-base
|
|
59
32
|
|
|
60
|
-
|
|
33
|
+
# pnpm
|
|
34
|
+
pnpm add -D @cooperco/nuxt-layer-base
|
|
61
35
|
|
|
62
|
-
|
|
36
|
+
# yarn
|
|
37
|
+
yarn add -D @cooperco/nuxt-layer-base
|
|
38
|
+
```
|
|
63
39
|
|
|
64
|
-
|
|
40
|
+
2) Extend the layer in your app’s Nuxt config
|
|
65
41
|
|
|
66
42
|
```ts
|
|
67
43
|
// nuxt.config.ts (in your app)
|
|
68
44
|
export default defineNuxtConfig({
|
|
69
|
-
extends: ['@cooperco/nuxt-layer-base']
|
|
70
|
-
i18n: {
|
|
71
|
-
locales: [
|
|
72
|
-
{ code: 'en', language: 'en-US', name: 'English' },
|
|
73
|
-
{ code: 'fr', language: 'fr-FR', name: 'Français' }
|
|
74
|
-
],
|
|
75
|
-
defaultLocale: 'en'
|
|
76
|
-
}
|
|
45
|
+
extends: ['@cooperco/nuxt-layer-base']
|
|
77
46
|
})
|
|
78
47
|
```
|
|
79
48
|
|
|
49
|
+
TypeScript strict mode and DevTools are active immediately. For ESLint, add a config in your app (see "ESLint in consumer apps").
|
|
50
|
+
|
|
51
|
+
### Internationalization (i18n)
|
|
52
|
+
|
|
53
|
+
This layer integrates the Nuxt i18n module. Use it directly in your components; preferred usage is an SFC `<i18n>` block. You can also call `useI18n()` in scripts.
|
|
54
|
+
|
|
80
55
|
Basic usage in a component:
|
|
81
56
|
|
|
82
57
|
```vue
|
|
@@ -86,202 +61,368 @@ const { t, locale } = useI18n()
|
|
|
86
61
|
|
|
87
62
|
<template>
|
|
88
63
|
<p>{{ t('hello') }}</p>
|
|
64
|
+
<small>Current locale: {{ locale }}</small>
|
|
65
|
+
<!-- Provide your own translation files in your app (e.g., locales/en.json) -->
|
|
89
66
|
</template>
|
|
90
67
|
```
|
|
91
68
|
|
|
92
|
-
|
|
69
|
+
Preferred: single‑file component local messages using an <i18n> block
|
|
93
70
|
|
|
94
|
-
|
|
71
|
+
```vue
|
|
72
|
+
<script setup lang="ts">
|
|
73
|
+
const { t } = useI18n()
|
|
74
|
+
</script>
|
|
95
75
|
|
|
96
|
-
|
|
76
|
+
<template>
|
|
77
|
+
<h1>{{ t('welcome') }}</h1>
|
|
78
|
+
<p>{{ t('cta') }}</p>
|
|
79
|
+
</template>
|
|
97
80
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
'
|
|
103
|
-
|
|
104
|
-
|
|
81
|
+
<i18n lang="json5">
|
|
82
|
+
{
|
|
83
|
+
en: {
|
|
84
|
+
welcome: 'Welcome',
|
|
85
|
+
cta: 'Click to continue'
|
|
86
|
+
},
|
|
87
|
+
fr: {
|
|
88
|
+
welcome: 'Bienvenue',
|
|
89
|
+
cta: 'Cliquez pour continuer'
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
</i18n>
|
|
105
93
|
```
|
|
106
94
|
|
|
107
|
-
|
|
108
|
-
This layer follows Nuxt 4's app directory structure:
|
|
109
|
-
- app/plugins/... for plugins (e.g., app/plugins/loggly.client.ts)
|
|
110
|
-
- app/composables/... for composables (e.g., app/composables/useLoggly.ts)
|
|
111
|
-
|
|
112
|
-
If you previously looked under plugins/ or composables/ at the layer root, note they have been relocated under app/ for Nuxt 4.
|
|
95
|
+
Both global files (e.g., `locales/en.json`) and per‑component `<i18n>` blocks are supported.
|
|
113
96
|
|
|
114
|
-
|
|
97
|
+
### Logging (optional, Loggly‑backed)
|
|
115
98
|
|
|
116
|
-
|
|
99
|
+
When enabled via environment variables, the base layer will:
|
|
100
|
+
- Capture server-side errors (Nitro request lifecycle)
|
|
101
|
+
- Capture client-side errors (Nuxt app errors, Vue component errors, global `window` errors, unhandled rejections)
|
|
102
|
+
- Provide a `useLogger()` composable for custom logs (and a deprecated `useLoggly()` wrapper)
|
|
103
|
+
- Proxy all logs through the canonical route `/api/log`, where payloads are normalized and sensitive fields are scrubbed before forwarding to Loggly
|
|
117
104
|
|
|
118
|
-
|
|
119
|
-
- Automatic server-side error capture (Nitro request errors)
|
|
120
|
-
- Automatic client-side error capture (Nuxt/Vue/global browser errors)
|
|
121
|
-
- Composable for custom logs from your features (useLoggly)
|
|
122
|
-
- Centralized PII scrubbing on the server proxy before forwarding to Loggly
|
|
105
|
+
Enable in your app with environment variables:
|
|
123
106
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
-
|
|
128
|
-
-
|
|
129
|
-
- LOGGLY_TAGS=nuxt,base,app-name,env (comma-separated; optional)
|
|
130
|
-
- LOG_LEVEL=error (optional; error|warn|info|debug)
|
|
131
|
-
- LOGGLY_ENDPOINT=https://logs-01.loggly.com/inputs (optional override)
|
|
107
|
+
- `LOGGLY_ENABLED=true`
|
|
108
|
+
- `LOGGLY_TOKEN=<your Loggly customer token>` (server-only)
|
|
109
|
+
- `LOGGLY_TAGS=app-name,env` (optional, comma-separated)
|
|
110
|
+
- `LOG_LEVEL=error` (optional; exposed to client for your own use)
|
|
111
|
+
- `LOGGLY_ENDPOINT=https://logs-01.loggly.com/inputs` (optional override, server-only)
|
|
132
112
|
|
|
133
113
|
Notes
|
|
134
|
-
-
|
|
135
|
-
- If
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
114
|
+
- The token is never exposed to the client. Browsers only post to your app’s `/api/log` endpoint (with `/api/loggly` kept as a deprecated alias).
|
|
115
|
+
- If logging is disabled or a token is not provided, the logging code is effectively a no-op and the API returns `{ ok: false, skipped: true }`.
|
|
116
|
+
- `LOG_LEVEL` is exposed via runtime config but the base layer does not filter logs by level; you may use it in your own app logic.
|
|
117
|
+
- Back‑compat alias: `/api/loggly` remains available but is deprecated. Prefer `/api/log`.
|
|
118
|
+
|
|
119
|
+
Tags behavior
|
|
120
|
+
- Automatic, source‑specific tags are appended for captured errors:
|
|
121
|
+
- Nuxt app‑level errors: `client`, `nuxt`
|
|
122
|
+
- Vue component errors: `client`, `vue`
|
|
123
|
+
- Global window `error` events: `client`, `window`
|
|
124
|
+
- Unhandled rejections: `client`, `promise`
|
|
125
|
+
- Server‑side errors: `server`
|
|
126
|
+
- Your `LOGGLY_TAGS` (e.g., `app-name,prod`) and per‑call tags from `useLogger()` are merged with the automatic tags; duplicates are removed and the list is capped at 10 tags.
|
|
127
|
+
|
|
128
|
+
Composable usage:
|
|
139
129
|
|
|
140
|
-
Example:
|
|
141
130
|
```ts
|
|
142
131
|
// inside a component or any composable
|
|
143
|
-
const log =
|
|
132
|
+
const log = useLogger()
|
|
144
133
|
await log.error('Checkout failed', { orderId, step: 'place-order' }, ['checkout'])
|
|
134
|
+
await log.warn('Slow response', { endpoint: '/api/orders', ms: 850 })
|
|
145
135
|
await log.info('User clicked CTA', { campaign: 'summer' }, ['marketing'])
|
|
136
|
+
await log.debug('State snapshot', { state })
|
|
146
137
|
```
|
|
147
138
|
|
|
148
|
-
|
|
149
|
-
- error(message, meta?, tags?)
|
|
150
|
-
- warn(message, meta?, tags?)
|
|
151
|
-
- info(message, meta?, tags?)
|
|
152
|
-
- debug(message, meta?, tags?)
|
|
153
|
-
|
|
154
|
-
Tags are merged with your global LOGGLY_TAGS (duplicates removed, limited to 10).
|
|
155
|
-
|
|
156
|
-
### 3) Auto-captured errors
|
|
157
|
-
When enabled, the base layer automatically forwards:
|
|
139
|
+
Automatically captured errors (when enabled):
|
|
158
140
|
- Server: request-time exceptions via a Nitro plugin
|
|
159
|
-
- Client: Nuxt app errors, Vue component errors, window error events,
|
|
160
|
-
|
|
161
|
-
These are forwarded to /api/loggly, which safely shapes the payload before sending to Loggly.
|
|
141
|
+
- Client: Nuxt app errors, Vue component errors, `window` error events, unhandled promise rejections
|
|
162
142
|
|
|
163
|
-
|
|
164
|
-
- The server proxy masks common sensitive keys (password
|
|
165
|
-
-
|
|
166
|
-
- Logging is fire-and-forget; failures to log do not block requests or UI.
|
|
143
|
+
Security and privacy:
|
|
144
|
+
- The server proxy masks common sensitive keys in `meta` (e.g., `password`, `token`, `authorization`, `ssn`, etc.)
|
|
145
|
+
- Logging is fire-and-forget; failures to log are swallowed to avoid breaking UX
|
|
167
146
|
|
|
168
|
-
|
|
169
|
-
1)
|
|
147
|
+
Verify locally:
|
|
148
|
+
1) Add env vars in your app (e.g., `.env.local`):
|
|
170
149
|
```
|
|
171
150
|
LOGGLY_ENABLED=true
|
|
172
151
|
LOGGLY_TOKEN=...your token...
|
|
173
|
-
LOGGLY_TAGS=
|
|
174
|
-
|
|
152
|
+
LOGGLY_TAGS=app-name,local
|
|
153
|
+
```
|
|
154
|
+
2) Start your app and trigger a test error.
|
|
155
|
+
3) Check the network tab for `POST /api/log` and then confirm the entry in Loggly.
|
|
156
|
+
|
|
157
|
+
Troubleshooting:
|
|
158
|
+
- No logs? Ensure `LOGGLY_ENABLED=true` and `LOGGLY_TOKEN` is present in the server environment.
|
|
159
|
+
- Browser CORS or network errors? The browser never talks to Loggly directly; it only posts to `/api/log`.
|
|
160
|
+
- High volume? Consider adding sampling or batching at the proxy later if needed.
|
|
161
|
+
|
|
162
|
+
### ESLint in consumer apps
|
|
163
|
+
|
|
164
|
+
This layer integrates ESLint via the `@nuxt/eslint` module. When you extend this layer, ESLint is available to your app; you only need to add a config file and scripts in your project.
|
|
165
|
+
|
|
166
|
+
1) Create `eslint.config.mjs` in your app root (copy from this layer and adjust only if necessary)
|
|
167
|
+
|
|
168
|
+
```js
|
|
169
|
+
// eslint.config.mjs (in your app)
|
|
170
|
+
// Nuxt generates a flat config at ./.nuxt/eslint.config.mjs
|
|
171
|
+
// We enable stylistic rules and add a few custom rules matching the base layer.
|
|
172
|
+
// @ts-check
|
|
173
|
+
import withNuxt from './.nuxt/eslint.config.mjs'
|
|
174
|
+
|
|
175
|
+
export default withNuxt({
|
|
176
|
+
rules: {
|
|
177
|
+
// Stylistic
|
|
178
|
+
'@stylistic/comma-dangle': ['error', 'never'],
|
|
179
|
+
|
|
180
|
+
// TypeScript
|
|
181
|
+
'@typescript-eslint/no-unused-vars': ['error', { caughtErrorsIgnorePattern: '^_' }],
|
|
182
|
+
|
|
183
|
+
// Vue template
|
|
184
|
+
'vue/html-closing-bracket-newline': ['error', { multiline: 'never', selfClosingTag: { multiline: 'never' } }],
|
|
185
|
+
'vue/html-closing-bracket-spacing': ['error', { selfClosingTag: 'never' }],
|
|
186
|
+
'vue/html-indent': ['error', 2, { baseIndent: 0 }]
|
|
187
|
+
}
|
|
188
|
+
})
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
2) Add scripts and run ESLint
|
|
192
|
+
|
|
193
|
+
```json
|
|
194
|
+
{
|
|
195
|
+
"scripts": {
|
|
196
|
+
"lint": "nuxt prepare && eslint .",
|
|
197
|
+
"lint:fix": "nuxt prepare && eslint . --fix"
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Notes
|
|
203
|
+
- The base layer enables stylistic mode via Nuxt (`eslint.config.stylistic: true`).
|
|
204
|
+
- Do not use Prettier alongside stylistic rules; remove Prettier configs/plugins from your app to avoid conflicts.
|
|
205
|
+
|
|
206
|
+
What this ESLint config enforces (in plain English)
|
|
207
|
+
|
|
208
|
+
- Base: It starts from Nuxt’s generated, project‑aware flat config (via `@nuxt/eslint`), then turns on stylistic mode for consistent formatting in JS/TS/Vue files.
|
|
209
|
+
- This layer adds a few focused rules to keep things consistent and practical:
|
|
210
|
+
- `@stylistic/comma-dangle: 'never'` — Disallow trailing commas in arrays/objects.
|
|
211
|
+
- Bad:
|
|
212
|
+
```js
|
|
213
|
+
const user = {
|
|
214
|
+
id: 1,
|
|
215
|
+
name: 'Ada',
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
- Good:
|
|
219
|
+
```js
|
|
220
|
+
const user = {
|
|
221
|
+
id: 1,
|
|
222
|
+
name: 'Ada'
|
|
223
|
+
}
|
|
224
|
+
```
|
|
225
|
+
- `@typescript-eslint/no-unused-vars` with `{ caughtErrorsIgnorePattern: '^_' }` — Flag unused variables, but allow a try/catch parameter that starts with `_` when you don’t use it.
|
|
226
|
+
- Good (explicitly ignored):
|
|
227
|
+
```ts
|
|
228
|
+
try {
|
|
229
|
+
risky()
|
|
230
|
+
}
|
|
231
|
+
catch (_err) {
|
|
232
|
+
// intentionally ignored
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
- `vue/html-closing-bracket-newline: { multiline: 'never', selfClosingTag: { multiline: 'never' } }` — Don’t put a newline before a closing bracket, even for multi‑line attribute lists.
|
|
236
|
+
- Bad:
|
|
237
|
+
```vue
|
|
238
|
+
<MyComp
|
|
239
|
+
a="1"
|
|
240
|
+
b="2"
|
|
241
|
+
/>
|
|
242
|
+
```
|
|
243
|
+
- Good:
|
|
244
|
+
```vue
|
|
245
|
+
<MyComp
|
|
246
|
+
a="1"
|
|
247
|
+
b="2" />
|
|
248
|
+
```
|
|
249
|
+
- `vue/html-closing-bracket-spacing: { selfClosingTag: 'never' }` — No space before a self‑closing `/>`.
|
|
250
|
+
- Bad: `<MyComp />`
|
|
251
|
+
- Good: `<MyComp/>`
|
|
252
|
+
- `vue/html-indent: [2, { baseIndent: 0 }]` — Two‑space indentation in templates.
|
|
253
|
+
- Example:
|
|
254
|
+
```vue
|
|
255
|
+
<template>
|
|
256
|
+
<div>
|
|
257
|
+
<span>Text</span>
|
|
258
|
+
</div>
|
|
259
|
+
</template>
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
### TypeScript in consumer apps
|
|
263
|
+
|
|
264
|
+
Strict mode is enabled by default. No configuration is required in your app.
|
|
265
|
+
|
|
266
|
+
Optional type checking script (install `vue-tsc` in your app):
|
|
267
|
+
|
|
268
|
+
```bash
|
|
269
|
+
npm i -D vue-tsc
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
```json
|
|
273
|
+
{
|
|
274
|
+
"scripts": {
|
|
275
|
+
"typecheck": "nuxt prepare && vue-tsc -b --noEmit"
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
### GitHub Actions in your app (linting and type checks)
|
|
281
|
+
|
|
282
|
+
To run the same checks in your app’s GitHub repository:
|
|
283
|
+
|
|
284
|
+
1) Copy workflow files into your app’s repo
|
|
285
|
+
- Create `.github/workflows/lint.yml` with a job that checks out your code, sets up Node, installs dependencies, and runs `npm run lint`.
|
|
286
|
+
- (Optional) Create `.github/workflows/typecheck.yml` to run `npm run typecheck` if you added the script.
|
|
287
|
+
|
|
288
|
+
Minimal examples you can adapt:
|
|
289
|
+
|
|
290
|
+
`.github/workflows/lint.yml`
|
|
291
|
+
```yaml
|
|
292
|
+
name: Lint
|
|
293
|
+
on:
|
|
294
|
+
pull_request:
|
|
295
|
+
branches: ['main']
|
|
296
|
+
jobs:
|
|
297
|
+
lint:
|
|
298
|
+
runs-on: ubuntu-latest
|
|
299
|
+
steps:
|
|
300
|
+
- uses: actions/checkout@v4
|
|
301
|
+
- uses: actions/setup-node@v4
|
|
302
|
+
with:
|
|
303
|
+
node-version: 20
|
|
304
|
+
- run: npm ci
|
|
305
|
+
- run: npm run lint
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
`.github/workflows/typecheck.yml`
|
|
309
|
+
```yaml
|
|
310
|
+
name: Typecheck
|
|
311
|
+
on:
|
|
312
|
+
pull_request:
|
|
313
|
+
branches: ['main']
|
|
314
|
+
jobs:
|
|
315
|
+
typecheck:
|
|
316
|
+
runs-on: ubuntu-latest
|
|
317
|
+
steps:
|
|
318
|
+
- uses: actions/checkout@v4
|
|
319
|
+
- uses: actions/setup-node@v4
|
|
320
|
+
with:
|
|
321
|
+
node-version: 20
|
|
322
|
+
- run: npm ci
|
|
323
|
+
- run: npm run typecheck
|
|
175
324
|
```
|
|
176
|
-
2) Start your app and induce an error (e.g., throw new Error('Test') in onMounted of a test component).
|
|
177
|
-
3) Watch your network tab for POST /api/loggly (200 OK). Then check Loggly for the entry with your tags.
|
|
178
325
|
|
|
179
|
-
|
|
180
|
-
-
|
|
181
|
-
-
|
|
182
|
-
|
|
326
|
+
2) Configure GitHub as needed
|
|
327
|
+
- Enable Actions in your repository settings (if disabled).
|
|
328
|
+
- If your app installs private packages, set appropriate repository secrets (e.g., `NPM_TOKEN`, organization PATs) and reference them in the workflow. For public packages only, no extra secrets are required.
|
|
329
|
+
|
|
330
|
+
3) Open a pull request to see the checks run.
|
|
331
|
+
|
|
332
|
+
—
|
|
183
333
|
|
|
334
|
+
## For maintainers: Working on this layer
|
|
184
335
|
|
|
185
|
-
|
|
336
|
+
### Repository layout and Nuxt 4 directories
|
|
186
337
|
|
|
187
|
-
|
|
338
|
+
- Layer root: `layers/base`
|
|
339
|
+
- Nuxt 4 app directories are used inside the layer:
|
|
340
|
+
- `app/plugins/...` (e.g., `app/plugins/loggly.client.ts`)
|
|
341
|
+
- `app/composables/...` (e.g., `app/composables/useLogger.ts`; deprecated wrapper: `app/composables/useLoggly.ts`)
|
|
342
|
+
|
|
343
|
+
### Development scripts
|
|
344
|
+
|
|
345
|
+
```bash
|
|
346
|
+
# From layers/base
|
|
347
|
+
npm install
|
|
348
|
+
npm run dev # start a dev server for the layer (via Nuxt)
|
|
349
|
+
npm run lint # ESLint (with nuxt prepare)
|
|
350
|
+
npm run lint:fix # ESLint --fix
|
|
351
|
+
npm run typecheck # vue-tsc
|
|
352
|
+
```
|
|
188
353
|
|
|
189
|
-
|
|
354
|
+
### Linting and TypeScript
|
|
190
355
|
|
|
191
|
-
|
|
192
|
-
-
|
|
193
|
-
- It includes a simple page to send logs via useLoggly and buttons to trigger client/server errors.
|
|
194
|
-
- The server exposes GET /api/boom to intentionally throw and test server‑side logging.
|
|
356
|
+
- TypeScript strict mode is enforced via `nuxt.config.ts`
|
|
357
|
+
- ESLint is provided by `@nuxt/eslint` with stylistic rules enabled and custom rules in `eslint.config.mjs`
|
|
195
358
|
|
|
196
|
-
|
|
197
|
-
1) From the repo root:
|
|
198
|
-
- cd playground && npm run dev
|
|
359
|
+
### Logging implementation (overview)
|
|
199
360
|
|
|
200
|
-
|
|
201
|
-
|
|
361
|
+
- Client plugin: `app/plugins/loggly.client.ts` hooks into Nuxt/Vue error streams and window events and posts to `/api/log`
|
|
362
|
+
- Composable: `app/composables/useLogger.ts` exposes `error|warn|info|debug` helpers posting to `/api/log` (deprecated wrapper `useLoggly.ts` delegates to `useLogger()`)
|
|
363
|
+
- Server plugin: `server/plugins/loggly.ts` hooks Nitro `error` events and posts to `/api/log`
|
|
364
|
+
- API endpoint (canonical): `server/api/log.post.ts` scrubs/normalizes payloads and forwards to Loggly using `LOGGLY_TOKEN`
|
|
365
|
+
- API endpoint (alias, deprecated): `server/api/loggly.post.ts` re-exports the canonical handler so `/api/loggly` continues to work
|
|
366
|
+
- Runtime config (see `nuxt.config.ts`):
|
|
367
|
+
- Server-only: `logglyToken`, `logglyEndpoint`
|
|
368
|
+
- Public: `logglyEnabled`, `logglyTags`, `logLevel`
|
|
369
|
+
- Supports both `LOGGLY_*` and `NUXT_PUBLIC_LOG_*` env vars where appropriate
|
|
202
370
|
|
|
203
|
-
|
|
204
|
-
LOGGLY_TOKEN=your-loggly-customer-token
|
|
205
|
-
LOGGLY_TAGS=nuxt,base,playground,local
|
|
206
|
-
LOG_LEVEL=debug
|
|
207
|
-
# Optional override
|
|
208
|
-
# LOGGLY_ENDPOINT=https://logs-01.loggly.com/inputs
|
|
371
|
+
### Environment files
|
|
209
372
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
- Click Call /api/boom to exercise the server plugin.
|
|
373
|
+
- `layers/base/.env` is only for developing the base layer directly (not used by consuming apps)
|
|
374
|
+
- For the repo playground, use `playground/.env`
|
|
375
|
+
- For downstream apps, use `.env` or `.env.local` in the app’s root
|
|
214
376
|
|
|
215
|
-
|
|
216
|
-
- If LOGGLY_ENABLED is not true or LOGGLY_TOKEN is missing, the server proxy will skip sending and respond with { ok: false, skipped: true }.
|
|
217
|
-
- The token is server‑only; it is never exposed to the client. The browser only talks to /api/loggly on your dev server.
|
|
377
|
+
### Playground
|
|
218
378
|
|
|
379
|
+
There is a simple playground app at `playground` that extends the base layer and exposes UI to trigger logging. This is useful for end‑to‑end verification during development.
|
|
219
380
|
|
|
220
|
-
|
|
381
|
+
Run from repo root:
|
|
382
|
+
```bash
|
|
383
|
+
cd playground
|
|
384
|
+
npm run dev
|
|
385
|
+
```
|
|
221
386
|
|
|
222
|
-
|
|
387
|
+
### Publishing to npm (tag-based)
|
|
223
388
|
|
|
224
|
-
This
|
|
389
|
+
This package is published via GitHub Actions when you push a tag that matches `base-vX.Y.Z`.
|
|
225
390
|
|
|
226
391
|
High-level flow:
|
|
227
|
-
- Bump the version in layers/base/package.json (SemVer)
|
|
228
|
-
- Commit and push
|
|
229
|
-
- Create and push a tag
|
|
230
|
-
-
|
|
392
|
+
- Bump the version in `layers/base/package.json` (SemVer)
|
|
393
|
+
- Commit and push to `main`
|
|
394
|
+
- Create and push a tag `base-vX.Y.Z` matching the version
|
|
395
|
+
- CI checks whether the version exists on npm and publishes if not
|
|
231
396
|
|
|
232
397
|
Important notes:
|
|
233
|
-
- Do NOT rely on npm version
|
|
234
|
-
-
|
|
235
|
-
- The workflow
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
npm version patch --no-git-tag-version # or minor | major
|
|
245
|
-
```
|
|
246
|
-
- PowerShell:
|
|
247
|
-
```powershell
|
|
248
|
-
Set-Location layers/base
|
|
249
|
-
npm version patch --no-git-tag-version # or minor | major
|
|
250
|
-
```
|
|
251
|
-
- Option B: manually edit the version field in layers/base/package.json (SemVer: MAJOR.MINOR.PATCH)
|
|
252
|
-
|
|
253
|
-
2) Commit and push the change (from repo root or layers/base)
|
|
398
|
+
- Do NOT rely on `npm version` to create the tag (it creates `vX.Y.Z`). Create `base-vX.Y.Z` yourself.
|
|
399
|
+
- `publishConfig.access` is set to `public`; publishes are public to npmjs.
|
|
400
|
+
- The workflow skips if the exact version already exists.
|
|
401
|
+
|
|
402
|
+
Step-by-step
|
|
403
|
+
1) Bump version without creating a tag:
|
|
404
|
+
```bash
|
|
405
|
+
cd layers/base
|
|
406
|
+
npm version patch --no-git-tag-version # or minor | major
|
|
407
|
+
```
|
|
408
|
+
2) Commit and push:
|
|
254
409
|
```bash
|
|
255
410
|
git add layers/base/package.json
|
|
256
411
|
git commit -m "chore(base): bump version"
|
|
257
412
|
git push origin main
|
|
258
413
|
```
|
|
414
|
+
3) Tag and push:
|
|
415
|
+
```bash
|
|
416
|
+
cd layers/base
|
|
417
|
+
VERSION=$(node -p "require('./package.json').version")
|
|
418
|
+
cd ../..
|
|
419
|
+
git tag "base-v$VERSION"
|
|
420
|
+
git push origin "base-v$VERSION"
|
|
421
|
+
```
|
|
422
|
+
4) CI will publish
|
|
423
|
+
- Workflow: `.github/workflows/publish-base.yml`
|
|
424
|
+
- Auth: `NPM_TOKEN` GitHub secret
|
|
259
425
|
|
|
260
|
-
|
|
261
|
-
-
|
|
262
|
-
|
|
263
|
-
```bash
|
|
264
|
-
cd layers/base
|
|
265
|
-
VERSION=$(node -p "require('./package.json').version")
|
|
266
|
-
cd ../..
|
|
267
|
-
git tag "base-v$VERSION"
|
|
268
|
-
git push origin "base-v$VERSION"
|
|
269
|
-
```
|
|
270
|
-
- PowerShell:
|
|
271
|
-
```powershell
|
|
272
|
-
Set-Location layers/base
|
|
273
|
-
$version = node -p "require('./package.json').version"
|
|
274
|
-
Set-Location ../..
|
|
275
|
-
git tag "base-v$version"
|
|
276
|
-
git push origin "base-v$version"
|
|
277
|
-
```
|
|
278
|
-
|
|
279
|
-
4) GitHub Actions will publish
|
|
280
|
-
- Workflow: .github/workflows/publish-base.yml
|
|
281
|
-
- Auth: uses NPM_TOKEN (Classic Automation token) configured as a GitHub secret
|
|
282
|
-
- Behavior: installs, checks npm view @cooperco/nuxt-layer-base@<version>, publishes if not found
|
|
283
|
-
|
|
284
|
-
### Troubleshooting
|
|
285
|
-
- Version already exists: bump the version again (patch/minor/major) and push a new tag.
|
|
286
|
-
- Auth errors (401/403): ensure NPM_TOKEN is set in repo secrets and org access is correct.
|
|
287
|
-
- Registry mismatch: this repo’s .npmrc pins @cooperco to https://registry.npmjs.org/; keep that file intact.
|
|
426
|
+
Troubleshooting
|
|
427
|
+
- Version exists: bump again and retag
|
|
428
|
+
- 401/403: ensure `NPM_TOKEN` is configured and has access
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type LogLevel = 'error' | 'warn' | 'info' | 'debug'
|
|
2
|
+
|
|
3
|
+
export const useLogger = () => {
|
|
4
|
+
const { public: pub } = useRuntimeConfig()
|
|
5
|
+
const enabled = !!pub?.logglyEnabled
|
|
6
|
+
const baseTags = (pub?.logglyTags || []) as string[]
|
|
7
|
+
|
|
8
|
+
const send = (
|
|
9
|
+
level: LogLevel,
|
|
10
|
+
message: string,
|
|
11
|
+
meta?: Record<string, unknown>,
|
|
12
|
+
tags: string[] = []
|
|
13
|
+
) => {
|
|
14
|
+
if (!enabled) return
|
|
15
|
+
const mergedTags = [...new Set([...baseTags, ...tags])].slice(0, 10)
|
|
16
|
+
return $fetch('/api/log', {
|
|
17
|
+
method: 'POST',
|
|
18
|
+
body: { level, message, meta, tags: mergedTags }
|
|
19
|
+
}).catch(() => {})
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
error: (m: string, meta?: Record<string, unknown>, tags?: string[]) => send('error', m, meta, tags),
|
|
24
|
+
warn: (m: string, meta?: Record<string, unknown>, tags?: string[]) => send('warn', m, meta, tags),
|
|
25
|
+
info: (m: string, meta?: Record<string, unknown>, tags?: string[]) => send('info', m, meta, tags),
|
|
26
|
+
debug: (m: string, meta?: Record<string, unknown>, tags?: string[]) => send('debug', m, meta, tags)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -1,28 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
import { useLogger } from './useLogger'
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
const send = (
|
|
9
|
-
level: LogLevel,
|
|
10
|
-
message: string,
|
|
11
|
-
meta?: Record<string, unknown>,
|
|
12
|
-
tags: string[] = []
|
|
13
|
-
) => {
|
|
14
|
-
if (!enabled) return
|
|
15
|
-
const mergedTags = [...new Set([...baseTags, ...tags])].slice(0, 10)
|
|
16
|
-
return $fetch('/api/loggly', {
|
|
17
|
-
method: 'POST',
|
|
18
|
-
body: { level, message, meta, tags: mergedTags }
|
|
19
|
-
}).catch(() => {})
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
return {
|
|
23
|
-
error: (m: string, meta?: Record<string, unknown>, tags?: string[]) => send('error', m, meta, tags),
|
|
24
|
-
warn: (m: string, meta?: Record<string, unknown>, tags?: string[]) => send('warn', m, meta, tags),
|
|
25
|
-
info: (m: string, meta?: Record<string, unknown>, tags?: string[]) => send('info', m, meta, tags),
|
|
26
|
-
debug: (m: string, meta?: Record<string, unknown>, tags?: string[]) => send('debug', m, meta, tags)
|
|
27
|
-
}
|
|
28
|
-
}
|
|
3
|
+
/**
|
|
4
|
+
* @deprecated Use `useLogger()` instead. This wrapper will be removed in a future major release.
|
|
5
|
+
*/
|
|
6
|
+
export const useLoggly = () => useLogger()
|
|
@@ -2,12 +2,49 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|
|
2
2
|
const config = useRuntimeConfig()
|
|
3
3
|
if (!config.public?.logglyEnabled) return
|
|
4
4
|
|
|
5
|
+
// Client-side de-duplication of errors that may surface through multiple channels
|
|
6
|
+
// (e.g., Vue errorHandler and window 'error'). We use a short-lived signature cache.
|
|
7
|
+
const recentSignatures = new Set<string>()
|
|
8
|
+
const remember = (sig: string) => {
|
|
9
|
+
recentSignatures.add(sig)
|
|
10
|
+
// Keep signatures briefly to collapse duplicate reports in the same tick/burst
|
|
11
|
+
setTimeout(() => {
|
|
12
|
+
recentSignatures.delete(sig)
|
|
13
|
+
}, 1000)
|
|
14
|
+
}
|
|
15
|
+
|
|
5
16
|
const post = (payload: Record<string, unknown>) =>
|
|
6
|
-
$fetch('/api/
|
|
17
|
+
$fetch('/api/log', { method: 'POST', body: payload }).catch(() => { /* swallow */ })
|
|
18
|
+
|
|
19
|
+
const hasMessage = (val: unknown): val is { message?: unknown } => {
|
|
20
|
+
return !!val && typeof val === 'object' && 'message' in (val as Record<string, unknown>)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const toSig = (err: unknown, fallbackMsg?: string): string | undefined => {
|
|
24
|
+
if (err instanceof Error) {
|
|
25
|
+
// name+message is sufficient across multiple client hooks; stacks can differ
|
|
26
|
+
return `${err.name}:${err.message}`
|
|
27
|
+
}
|
|
28
|
+
if (typeof err === 'string') return `str:${err}`
|
|
29
|
+
if (hasMessage(err)) {
|
|
30
|
+
return `obj:${String(err.message)}`
|
|
31
|
+
}
|
|
32
|
+
if (fallbackMsg) return `msg:${fallbackMsg}`
|
|
33
|
+
return undefined
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const postOnce = (err: unknown, payload: Record<string, unknown>) => {
|
|
37
|
+
const sig = toSig(err, String(payload?.message ?? ''))
|
|
38
|
+
if (sig) {
|
|
39
|
+
if (recentSignatures.has(sig)) return
|
|
40
|
+
remember(sig)
|
|
41
|
+
}
|
|
42
|
+
return post(payload)
|
|
43
|
+
}
|
|
7
44
|
|
|
8
45
|
// Nuxt app-level errors
|
|
9
46
|
nuxtApp.hook('app:error', (err) => {
|
|
10
|
-
|
|
47
|
+
postOnce(err, {
|
|
11
48
|
level: 'error',
|
|
12
49
|
message: extractMessage(err) || 'App error',
|
|
13
50
|
error: toErrorShape(err),
|
|
@@ -18,7 +55,7 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|
|
18
55
|
// Vue component-level errors
|
|
19
56
|
const originalHandler = nuxtApp.vueApp.config.errorHandler
|
|
20
57
|
nuxtApp.vueApp.config.errorHandler = (err, instance, info) => {
|
|
21
|
-
|
|
58
|
+
postOnce(err, {
|
|
22
59
|
level: 'error',
|
|
23
60
|
message: extractMessage(err) || 'Vue error',
|
|
24
61
|
error: toErrorShape(err),
|
|
@@ -30,11 +67,11 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|
|
30
67
|
|
|
31
68
|
if (import.meta.client) {
|
|
32
69
|
window.addEventListener('error', (event: ErrorEvent) => {
|
|
33
|
-
|
|
70
|
+
postOnce(event.error, { level: 'error', message: event.message, error: toErrorShape(event.error), tags: ['client', 'window'] })
|
|
34
71
|
})
|
|
35
72
|
window.addEventListener('unhandledrejection', (event: PromiseRejectionEvent) => {
|
|
36
73
|
const reason = event.reason
|
|
37
|
-
|
|
74
|
+
postOnce(reason, { level: 'error', message: extractMessage(reason) || 'Unhandled rejection', error: toErrorShape(reason), tags: ['client', 'promise'] })
|
|
38
75
|
})
|
|
39
76
|
}
|
|
40
77
|
})
|
package/eslint.config.mjs
CHANGED
|
@@ -6,7 +6,12 @@ export default withNuxt({
|
|
|
6
6
|
/* Stylistic */
|
|
7
7
|
'@stylistic/comma-dangle': [
|
|
8
8
|
'error',
|
|
9
|
-
'
|
|
9
|
+
'only-multiline'
|
|
10
|
+
],
|
|
11
|
+
|
|
12
|
+
'@stylistic/no-tabs': [
|
|
13
|
+
'error',
|
|
14
|
+
{ allowIndentationTabs: true }
|
|
10
15
|
],
|
|
11
16
|
|
|
12
17
|
/* TS */
|
|
@@ -25,7 +30,7 @@ export default withNuxt({
|
|
|
25
30
|
{ selfClosingTag: 'never' }
|
|
26
31
|
],
|
|
27
32
|
'vue/html-indent': [
|
|
28
|
-
'error',
|
|
33
|
+
'error', 'tab',
|
|
29
34
|
{ baseIndent: 0 }
|
|
30
35
|
]
|
|
31
36
|
}
|
package/nuxt.config.ts
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cooperco/nuxt-layer-base",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./nuxt.config.ts",
|
|
6
6
|
"scripts": {
|
|
@@ -22,13 +22,13 @@
|
|
|
22
22
|
"author": "cooperco",
|
|
23
23
|
"license": "MIT",
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@nuxt/eslint": "^1.
|
|
26
|
-
"@nuxtjs/i18n": "^10.1
|
|
27
|
-
"nuxt": "4.1
|
|
25
|
+
"@nuxt/eslint": "^1.10.0",
|
|
26
|
+
"@nuxtjs/i18n": "^10.2.1",
|
|
27
|
+
"nuxt": "^4.2.1"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
|
-
"@nuxt/test-utils": "^3.
|
|
31
|
-
"eslint": "^9.
|
|
32
|
-
"vue-tsc": "^3.1.
|
|
30
|
+
"@nuxt/test-utils": "^3.20.1",
|
|
31
|
+
"eslint": "^9.39.1",
|
|
32
|
+
"vue-tsc": "^3.1.4"
|
|
33
33
|
}
|
|
34
34
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { defineEventHandler, readBody } from 'h3'
|
|
2
|
+
|
|
3
|
+
type LogglyBody = {
|
|
4
|
+
level?: string
|
|
5
|
+
message?: string
|
|
6
|
+
error?: unknown
|
|
7
|
+
tags?: string[]
|
|
8
|
+
meta?: Record<string, unknown>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const sanitizeMeta = (input: unknown) => {
|
|
12
|
+
if (!input || typeof input !== 'object') return undefined
|
|
13
|
+
const blocked = ['password', 'token', 'authorization', 'auth', 'ssn', 'creditcard', 'credit_card']
|
|
14
|
+
const out: Record<string, unknown> = {}
|
|
15
|
+
for (const [k, v] of Object.entries(input as Record<string, unknown>)) {
|
|
16
|
+
out[k] = blocked.includes(k.toLowerCase()) ? '[REDACTED]' : v
|
|
17
|
+
}
|
|
18
|
+
return out
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const serializeError = (err: unknown) => {
|
|
22
|
+
if (!err || typeof err !== 'object') return undefined
|
|
23
|
+
const e = err as { name?: string, message?: unknown, stack?: unknown }
|
|
24
|
+
return {
|
|
25
|
+
name: e.name || 'Error',
|
|
26
|
+
message: String(e.message ?? 'Error'),
|
|
27
|
+
stack: typeof e.stack === 'string' ? e.stack.split('\n').slice(0, 20) : undefined
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export default defineEventHandler(async (event) => {
|
|
32
|
+
const { logglyToken, logglyEndpoint, public: pub } = useRuntimeConfig()
|
|
33
|
+
if (!pub?.logglyEnabled || !logglyToken) {
|
|
34
|
+
return { ok: false, skipped: true }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const body = await readBody<LogglyBody>(event)
|
|
38
|
+
|
|
39
|
+
const scrubbed = {
|
|
40
|
+
level: body?.level ?? 'error',
|
|
41
|
+
message: body?.message?.toString().slice(0, 5000),
|
|
42
|
+
tags: [...new Set([...(pub.logglyTags || []), ...(body?.tags || [])])].slice(0, 10),
|
|
43
|
+
meta: sanitizeMeta(body?.meta),
|
|
44
|
+
error: body?.error ? serializeError(body.error) : undefined,
|
|
45
|
+
ts: new Date().toISOString()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const url = `${logglyEndpoint}/${encodeURIComponent(logglyToken)}/tag/${encodeURIComponent(scrubbed.tags.join(','))}`
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
await $fetch(url, { method: 'POST', body: scrubbed })
|
|
52
|
+
return { ok: true }
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// do not throw here to avoid cascading failures
|
|
56
|
+
return { ok: false }
|
|
57
|
+
}
|
|
58
|
+
})
|
|
@@ -1,58 +1 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
type LogglyBody = {
|
|
4
|
-
level?: string
|
|
5
|
-
message?: string
|
|
6
|
-
error?: unknown
|
|
7
|
-
tags?: string[]
|
|
8
|
-
meta?: Record<string, unknown>
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
const sanitizeMeta = (input: unknown) => {
|
|
12
|
-
if (!input || typeof input !== 'object') return undefined
|
|
13
|
-
const blocked = ['password', 'token', 'authorization', 'auth', 'ssn', 'creditcard', 'credit_card']
|
|
14
|
-
const out: Record<string, unknown> = {}
|
|
15
|
-
for (const [k, v] of Object.entries(input as Record<string, unknown>)) {
|
|
16
|
-
out[k] = blocked.includes(k.toLowerCase()) ? '[REDACTED]' : v
|
|
17
|
-
}
|
|
18
|
-
return out
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const serializeError = (err: unknown) => {
|
|
22
|
-
if (!err || typeof err !== 'object') return undefined
|
|
23
|
-
const e = err as { name?: string, message?: unknown, stack?: unknown }
|
|
24
|
-
return {
|
|
25
|
-
name: e.name || 'Error',
|
|
26
|
-
message: String(e.message ?? 'Error'),
|
|
27
|
-
stack: typeof e.stack === 'string' ? e.stack.split('\n').slice(0, 20) : undefined
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export default defineEventHandler(async (event) => {
|
|
32
|
-
const { logglyToken, logglyEndpoint, public: pub } = useRuntimeConfig()
|
|
33
|
-
if (!pub?.logglyEnabled || !logglyToken) {
|
|
34
|
-
return { ok: false, skipped: true }
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
const body = await readBody<LogglyBody>(event)
|
|
38
|
-
|
|
39
|
-
const scrubbed = {
|
|
40
|
-
level: body?.level ?? 'error',
|
|
41
|
-
message: body?.message?.toString().slice(0, 5000),
|
|
42
|
-
tags: [...new Set([...(pub.logglyTags || []), ...(body?.tags || [])])].slice(0, 10),
|
|
43
|
-
meta: sanitizeMeta(body?.meta),
|
|
44
|
-
error: body?.error ? serializeError(body.error) : undefined,
|
|
45
|
-
ts: new Date().toISOString()
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
const url = `${logglyEndpoint}/${encodeURIComponent(logglyToken)}/tag/${encodeURIComponent(scrubbed.tags.join(','))}`
|
|
49
|
-
|
|
50
|
-
try {
|
|
51
|
-
await $fetch(url, { method: 'POST', body: scrubbed })
|
|
52
|
-
return { ok: true }
|
|
53
|
-
}
|
|
54
|
-
catch {
|
|
55
|
-
// do not throw here to avoid cascading failures
|
|
56
|
-
return { ok: false }
|
|
57
|
-
}
|
|
58
|
-
})
|
|
1
|
+
export { default } from './log.post'
|
package/server/plugins/loggly.ts
CHANGED
|
@@ -3,13 +3,12 @@ type NitroErrorCtx = {
|
|
|
3
3
|
path?: string
|
|
4
4
|
method?: string
|
|
5
5
|
node?: { res?: { statusCode?: number } }
|
|
6
|
+
context?: Record<string, unknown>
|
|
6
7
|
}
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
const toErrorShape = (err: unknown) => {
|
|
10
|
-
if (!err || typeof err !== 'object')
|
|
11
|
-
return undefined
|
|
12
|
-
}
|
|
11
|
+
if (!err || typeof err !== 'object') return undefined
|
|
13
12
|
const e = err as { name?: string, message?: unknown, stack?: unknown }
|
|
14
13
|
return {
|
|
15
14
|
name: e.name || 'Error',
|
|
@@ -22,13 +21,58 @@ export default defineNitroPlugin((nitroApp) => {
|
|
|
22
21
|
const config = useRuntimeConfig()
|
|
23
22
|
if (!config.public?.logglyEnabled || !config.logglyToken) return
|
|
24
23
|
|
|
24
|
+
// Minimal, process-local de-duplication to cover cases where Nitro emits
|
|
25
|
+
// the same error twice (e.g., once without a ctx.event and then again with it).
|
|
26
|
+
// We keep a tiny TTL so distinct errors still flow freely.
|
|
27
|
+
const recent = new Set<string>()
|
|
28
|
+
const remember = (sig: string) => {
|
|
29
|
+
recent.add(sig)
|
|
30
|
+
setTimeout(() => recent.delete(sig), 1000) // 1s window
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const toSig = (error: unknown, ctx?: NitroErrorCtx) => {
|
|
34
|
+
let base: string | undefined
|
|
35
|
+
if (error && typeof error === 'object') {
|
|
36
|
+
const e = error as { name?: string, message?: unknown }
|
|
37
|
+
base = `${e.name || 'Error'}:${String(e.message ?? 'Error')}`
|
|
38
|
+
}
|
|
39
|
+
else if (typeof error === 'string') {
|
|
40
|
+
base = `str:${error}`
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
base = 'unknown'
|
|
44
|
+
}
|
|
45
|
+
const m = ctx?.event?.method
|
|
46
|
+
const p = ctx?.event?.path
|
|
47
|
+
const s = ctx?.event?.node?.res?.statusCode
|
|
48
|
+
return [base, m, p, s].filter(v => v !== undefined && v !== '').join('|')
|
|
49
|
+
}
|
|
50
|
+
|
|
25
51
|
nitroApp.hooks.hook('error', async (error: unknown, ctx?: NitroErrorCtx) => {
|
|
26
52
|
try {
|
|
27
|
-
|
|
53
|
+
// Per-request guard: only log once per request lifecycle
|
|
54
|
+
const LOGGED_ONCE_KEY = '__loggerLoggedOnce' as const
|
|
55
|
+
type CtxStore = Record<string, unknown> & { [LOGGED_ONCE_KEY]?: boolean }
|
|
56
|
+
if (ctx?.event) {
|
|
57
|
+
const current = (ctx.event.context as CtxStore) || (ctx.event.context = {} as CtxStore)
|
|
58
|
+
if (current[LOGGED_ONCE_KEY]) return
|
|
59
|
+
current[LOGGED_ONCE_KEY] = true
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Process-local, short-lived dedup guard to catch duplicate emissions
|
|
63
|
+
const sig = toSig(error, ctx)
|
|
64
|
+
if (sig) {
|
|
65
|
+
if (recent.has(sig)) return
|
|
66
|
+
remember(sig)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
await $fetch('/api/log', {
|
|
28
70
|
method: 'POST',
|
|
29
71
|
body: {
|
|
30
72
|
level: 'error',
|
|
31
|
-
message: (typeof error === 'object' && error && 'message' in error)
|
|
73
|
+
message: (typeof error === 'object' && error && 'message' in error)
|
|
74
|
+
? String((error as { message?: unknown }).message)
|
|
75
|
+
: 'Server error',
|
|
32
76
|
error: toErrorShape(error),
|
|
33
77
|
meta: {
|
|
34
78
|
url: ctx?.event?.path,
|