@cooperco/nuxt-layer-base 1.0.5 → 1.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/README.md +371 -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 +18 -3
- package/nuxt.config.ts +4 -0
- 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
|
|
13
|
-
|
|
14
|
-
## Development
|
|
15
|
-
|
|
16
|
-
```bash
|
|
17
|
-
# Install dependencies
|
|
18
|
-
npm install
|
|
7
|
+
—
|
|
19
8
|
|
|
20
|
-
|
|
21
|
-
npm run dev
|
|
22
|
-
|
|
23
|
-
# Run ESLint
|
|
24
|
-
npm run lint
|
|
9
|
+
## About Nuxt Layers
|
|
25
10
|
|
|
26
|
-
|
|
27
|
-
npm run lint:fix
|
|
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.
|
|
28
12
|
|
|
29
|
-
|
|
30
|
-
npm run typecheck
|
|
31
|
-
```
|
|
13
|
+
Learn more in the official Nuxt Layers documentation: https://nuxt.com/docs/guide/going-further/layers
|
|
32
14
|
|
|
33
|
-
##
|
|
15
|
+
## For app developers: Using this layer
|
|
34
16
|
|
|
35
|
-
###
|
|
17
|
+
### What this layer provides
|
|
36
18
|
|
|
37
|
-
-
|
|
38
|
-
-
|
|
39
|
-
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
- **Testing Utilities:** Built-in test utils for comprehensive testing
|
|
43
|
-
|
|
44
|
-
### Configuration Details
|
|
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,397 @@ 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)
|
|
95
|
+
Both global files (e.g., `locales/en.json`) and per‑component `<i18n>` blocks are supported.
|
|
111
96
|
|
|
112
|
-
|
|
97
|
+
### Logging (optional, Loggly‑backed)
|
|
113
98
|
|
|
114
|
-
|
|
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
|
|
115
104
|
|
|
116
|
-
|
|
105
|
+
Enable in your app with environment variables:
|
|
117
106
|
|
|
118
|
-
|
|
119
|
-
-
|
|
120
|
-
-
|
|
121
|
-
-
|
|
122
|
-
-
|
|
123
|
-
|
|
124
|
-
### 1) Enable in your app (env vars)
|
|
125
|
-
Set the following environment variables in your consuming app (do not expose the token on the client):
|
|
126
|
-
|
|
127
|
-
- LOGGLY_ENABLED=true
|
|
128
|
-
- LOGGLY_TOKEN=your-loggly-customer-token
|
|
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,
|
|
141
|
+
- Client: Nuxt app errors, Vue component errors, `window` error events, unhandled promise rejections
|
|
160
142
|
|
|
161
|
-
|
|
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
|
|
162
146
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
- Prefer adding any app-specific sensitive keys to your meta scrubbing in the proxy if needed.
|
|
166
|
-
- Logging is fire-and-forget; failures to log do not block requests or UI.
|
|
167
|
-
|
|
168
|
-
### 5) Verifying locally
|
|
169
|
-
1) In your consuming app, set env vars (e.g. in .env.local):
|
|
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': [
|
|
179
|
+
'error',
|
|
180
|
+
'only-multiline'
|
|
181
|
+
],
|
|
182
|
+
'@stylistic/no-tabs': [
|
|
183
|
+
'error',
|
|
184
|
+
{ allowIndentationTabs: true }
|
|
185
|
+
],
|
|
186
|
+
|
|
187
|
+
/* TS */
|
|
188
|
+
'@typescript-eslint/no-unused-vars': [
|
|
189
|
+
'error',
|
|
190
|
+
{ caughtErrorsIgnorePattern: '^_' }
|
|
191
|
+
],
|
|
192
|
+
|
|
193
|
+
/* Vue */
|
|
194
|
+
'vue/html-closing-bracket-newline': [
|
|
195
|
+
'error',
|
|
196
|
+
{ multiline: 'never', selfClosingTag: { multiline: 'never' } }
|
|
197
|
+
],
|
|
198
|
+
'vue/html-closing-bracket-spacing': [
|
|
199
|
+
'error',
|
|
200
|
+
{ selfClosingTag: 'never' }
|
|
201
|
+
],
|
|
202
|
+
'vue/html-indent': [
|
|
203
|
+
'error', 'tab',
|
|
204
|
+
{ baseIndent: 0 }
|
|
205
|
+
],
|
|
206
|
+
'vue/multi-word-component-names': ['error', {
|
|
207
|
+
ignores: []
|
|
208
|
+
}],
|
|
209
|
+
'vue/component-name-in-template-casing': [
|
|
210
|
+
'error',
|
|
211
|
+
'kebab-case',
|
|
212
|
+
{ registeredComponentsOnly: false, ignores: [] }
|
|
213
|
+
],
|
|
214
|
+
'vue/component-options-name-casing': ['error', 'kebab-case'],
|
|
215
|
+
'vue/component-definition-name-casing': ['error', 'kebab-case']
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
2) Add scripts and run ESLint
|
|
221
|
+
|
|
222
|
+
```json
|
|
223
|
+
{
|
|
224
|
+
"scripts": {
|
|
225
|
+
"lint": "nuxt prepare && eslint .",
|
|
226
|
+
"lint:fix": "nuxt prepare && eslint . --fix"
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Notes
|
|
232
|
+
- The base layer enables stylistic mode via Nuxt (`eslint.config.stylistic: true`).
|
|
233
|
+
- Do not use Prettier alongside stylistic rules; remove Prettier configs/plugins from your app to avoid conflicts.
|
|
234
|
+
- Tabs are allowed for indentation (including in Vue templates). The `no-tabs` rule is enabled but permits indentation tabs.
|
|
235
|
+
|
|
236
|
+
What this ESLint config enforces (in plain English)
|
|
237
|
+
|
|
238
|
+
- 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.
|
|
239
|
+
- This layer adds a few focused rules to keep things consistent and practical:
|
|
240
|
+
- `@stylistic/comma-dangle: 'only-multiline'` — Allow trailing commas only when the list spans multiple lines; disallow them on single‑line lists.
|
|
241
|
+
- Good (multiline, trailing comma ok):
|
|
242
|
+
```js
|
|
243
|
+
const user = {
|
|
244
|
+
id: 1,
|
|
245
|
+
name: 'Ada',
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
- Good (single‑line, no trailing comma): `const arr = [1, 2, 3]`
|
|
249
|
+
- `@stylistic/no-tabs` with `{ allowIndentationTabs: true }` — Tabs are allowed for indentation but tabs elsewhere are flagged.
|
|
250
|
+
- `@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.
|
|
251
|
+
- Good (explicitly ignored):
|
|
252
|
+
```ts
|
|
253
|
+
try {
|
|
254
|
+
risky()
|
|
255
|
+
}
|
|
256
|
+
catch (_err) {
|
|
257
|
+
// intentionally ignored
|
|
258
|
+
}
|
|
259
|
+
```
|
|
260
|
+
- `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.
|
|
261
|
+
- Bad:
|
|
262
|
+
```vue
|
|
263
|
+
<my-comp
|
|
264
|
+
a="1"
|
|
265
|
+
b="2"
|
|
266
|
+
/>
|
|
267
|
+
```
|
|
268
|
+
- Good:
|
|
269
|
+
```vue
|
|
270
|
+
<my-comp
|
|
271
|
+
a="1"
|
|
272
|
+
b="2" />
|
|
273
|
+
```
|
|
274
|
+
- `vue/html-closing-bracket-spacing: { selfClosingTag: 'never' }` — No space before a self‑closing `/>`.
|
|
275
|
+
- Bad: `<my-comp />`
|
|
276
|
+
- Good: `<my-comp/>`
|
|
277
|
+
- `vue/html-indent: ['tab', { baseIndent: 0 }]` — Use tabs for indentation in Vue templates.
|
|
278
|
+
- Example:
|
|
279
|
+
```vue
|
|
280
|
+
<template>
|
|
281
|
+
<div>
|
|
282
|
+
<span>Text</span>
|
|
283
|
+
</div>
|
|
284
|
+
</template>
|
|
285
|
+
```
|
|
286
|
+
- `vue/multi-word-component-names` — Enforce multi‑word component names (no single generic names like `Header` conflicts). Configure ignores if needed.
|
|
287
|
+
- `vue/component-name-in-template-casing: 'kebab-case'` — In templates, component tags must be kebab‑case (e.g., `<my-widget/>`, not `<MyWidget/>`).
|
|
288
|
+
- `vue/component-options-name-casing: 'kebab-case'` — In SFC component options, the `name` should be kebab‑case.
|
|
289
|
+
- `vue/component-definition-name-casing: 'kebab-case'` — For component definitions in script, enforce kebab‑case names.
|
|
290
|
+
|
|
291
|
+
### TypeScript in consumer apps
|
|
292
|
+
|
|
293
|
+
Strict mode is enabled by default. No configuration is required in your app.
|
|
294
|
+
|
|
295
|
+
Optional type checking script (install `vue-tsc` in your app):
|
|
296
|
+
|
|
297
|
+
```bash
|
|
298
|
+
npm i -D vue-tsc
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
```json
|
|
302
|
+
{
|
|
303
|
+
"scripts": {
|
|
304
|
+
"typecheck": "nuxt prepare && vue-tsc -b --noEmit"
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
### GitHub Actions in your app (linting and type checks)
|
|
310
|
+
|
|
311
|
+
To run the same checks in your app’s GitHub repository:
|
|
312
|
+
|
|
313
|
+
1) Copy workflow files into your app’s repo
|
|
314
|
+
- Create `.github/workflows/lint.yml` with a job that checks out your code, sets up Node, installs dependencies, and runs `npm run lint`.
|
|
315
|
+
- (Optional) Create `.github/workflows/typecheck.yml` to run `npm run typecheck` if you added the script.
|
|
316
|
+
|
|
317
|
+
Minimal examples you can adapt:
|
|
318
|
+
|
|
319
|
+
`.github/workflows/lint.yml`
|
|
320
|
+
```yaml
|
|
321
|
+
name: Lint
|
|
322
|
+
on:
|
|
323
|
+
pull_request:
|
|
324
|
+
branches: ['main']
|
|
325
|
+
jobs:
|
|
326
|
+
lint:
|
|
327
|
+
runs-on: ubuntu-latest
|
|
328
|
+
steps:
|
|
329
|
+
- uses: actions/checkout@v4
|
|
330
|
+
- uses: actions/setup-node@v4
|
|
331
|
+
with:
|
|
332
|
+
node-version: 20
|
|
333
|
+
- run: npm ci
|
|
334
|
+
- run: npm run lint
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
`.github/workflows/typecheck.yml`
|
|
338
|
+
```yaml
|
|
339
|
+
name: Typecheck
|
|
340
|
+
on:
|
|
341
|
+
pull_request:
|
|
342
|
+
branches: ['main']
|
|
343
|
+
jobs:
|
|
344
|
+
typecheck:
|
|
345
|
+
runs-on: ubuntu-latest
|
|
346
|
+
steps:
|
|
347
|
+
- uses: actions/checkout@v4
|
|
348
|
+
- uses: actions/setup-node@v4
|
|
349
|
+
with:
|
|
350
|
+
node-version: 20
|
|
351
|
+
- run: npm ci
|
|
352
|
+
- run: npm run typecheck
|
|
175
353
|
```
|
|
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
354
|
|
|
179
|
-
|
|
180
|
-
-
|
|
181
|
-
-
|
|
182
|
-
- Too much volume? Consider adding sampling or batching later; the proxy makes this easy to change centrally.
|
|
355
|
+
2) Configure GitHub as needed
|
|
356
|
+
- Enable Actions in your repository settings (if disabled).
|
|
357
|
+
- 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.
|
|
183
358
|
|
|
359
|
+
3) Open a pull request to see the checks run.
|
|
184
360
|
|
|
185
|
-
|
|
361
|
+
—
|
|
186
362
|
|
|
187
|
-
|
|
363
|
+
## For maintainers: Working on this layer
|
|
364
|
+
|
|
365
|
+
### Repository layout and Nuxt 4 directories
|
|
366
|
+
|
|
367
|
+
- Layer root: `layers/base`
|
|
368
|
+
- Nuxt 4 app directories are used inside the layer:
|
|
369
|
+
- `app/plugins/...` (e.g., `app/plugins/loggly.client.ts`)
|
|
370
|
+
- `app/composables/...` (e.g., `app/composables/useLogger.ts`; deprecated wrapper: `app/composables/useLoggly.ts`)
|
|
371
|
+
|
|
372
|
+
### Development scripts
|
|
373
|
+
|
|
374
|
+
```bash
|
|
375
|
+
# From layers/base
|
|
376
|
+
npm install
|
|
377
|
+
npm run dev # start a dev server for the layer (via Nuxt)
|
|
378
|
+
npm run lint # ESLint (with nuxt prepare)
|
|
379
|
+
npm run lint:fix # ESLint --fix
|
|
380
|
+
npm run typecheck # vue-tsc
|
|
381
|
+
```
|
|
188
382
|
|
|
189
|
-
|
|
383
|
+
### Linting and TypeScript
|
|
190
384
|
|
|
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.
|
|
385
|
+
- TypeScript strict mode is enforced via `nuxt.config.ts`
|
|
386
|
+
- ESLint is provided by `@nuxt/eslint` with stylistic rules enabled and custom rules in `eslint.config.mjs`
|
|
195
387
|
|
|
196
|
-
|
|
197
|
-
1) From the repo root:
|
|
198
|
-
- cd playground && npm run dev
|
|
388
|
+
### Logging implementation (overview)
|
|
199
389
|
|
|
200
|
-
|
|
201
|
-
|
|
390
|
+
- Client plugin: `app/plugins/loggly.client.ts` hooks into Nuxt/Vue error streams and window events and posts to `/api/log`
|
|
391
|
+
- Composable: `app/composables/useLogger.ts` exposes `error|warn|info|debug` helpers posting to `/api/log` (deprecated wrapper `useLoggly.ts` delegates to `useLogger()`)
|
|
392
|
+
- Server plugin: `server/plugins/loggly.ts` hooks Nitro `error` events and posts to `/api/log`
|
|
393
|
+
- API endpoint (canonical): `server/api/log.post.ts` scrubs/normalizes payloads and forwards to Loggly using `LOGGLY_TOKEN`
|
|
394
|
+
- API endpoint (alias, deprecated): `server/api/loggly.post.ts` re-exports the canonical handler so `/api/loggly` continues to work
|
|
395
|
+
- Runtime config (see `nuxt.config.ts`):
|
|
396
|
+
- Server-only: `logglyToken`, `logglyEndpoint`
|
|
397
|
+
- Public: `logglyEnabled`, `logglyTags`, `logLevel`
|
|
398
|
+
- Supports both `LOGGLY_*` and `NUXT_PUBLIC_LOG_*` env vars where appropriate
|
|
202
399
|
|
|
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
|
|
400
|
+
### Environment files
|
|
209
401
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
- Click Call /api/boom to exercise the server plugin.
|
|
402
|
+
- `layers/base/.env` is only for developing the base layer directly (not used by consuming apps)
|
|
403
|
+
- For the repo playground, use `playground/.env`
|
|
404
|
+
- For downstream apps, use `.env` or `.env.local` in the app’s root
|
|
214
405
|
|
|
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.
|
|
406
|
+
### Playground
|
|
218
407
|
|
|
408
|
+
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
409
|
|
|
220
|
-
|
|
410
|
+
Run from repo root:
|
|
411
|
+
```bash
|
|
412
|
+
cd playground
|
|
413
|
+
npm run dev
|
|
414
|
+
```
|
|
221
415
|
|
|
222
|
-
|
|
416
|
+
### Publishing to npm (tag-based)
|
|
223
417
|
|
|
224
|
-
This
|
|
418
|
+
This package is published via GitHub Actions when you push a tag that matches `base-vX.Y.Z`.
|
|
225
419
|
|
|
226
420
|
High-level flow:
|
|
227
|
-
- Bump the version in layers/base/package.json (SemVer)
|
|
228
|
-
- Commit and push
|
|
229
|
-
- Create and push a tag
|
|
230
|
-
-
|
|
421
|
+
- Bump the version in `layers/base/package.json` (SemVer)
|
|
422
|
+
- Commit and push to `main`
|
|
423
|
+
- Create and push a tag `base-vX.Y.Z` matching the version
|
|
424
|
+
- CI checks whether the version exists on npm and publishes if not
|
|
231
425
|
|
|
232
426
|
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)
|
|
427
|
+
- Do NOT rely on `npm version` to create the tag (it creates `vX.Y.Z`). Create `base-vX.Y.Z` yourself.
|
|
428
|
+
- `publishConfig.access` is set to `public`; publishes are public to npmjs.
|
|
429
|
+
- The workflow skips if the exact version already exists.
|
|
430
|
+
|
|
431
|
+
Step-by-step
|
|
432
|
+
1) Bump version without creating a tag:
|
|
433
|
+
```bash
|
|
434
|
+
cd layers/base
|
|
435
|
+
npm version patch --no-git-tag-version # or minor | major
|
|
436
|
+
```
|
|
437
|
+
2) Commit and push:
|
|
254
438
|
```bash
|
|
255
439
|
git add layers/base/package.json
|
|
256
440
|
git commit -m "chore(base): bump version"
|
|
257
441
|
git push origin main
|
|
258
442
|
```
|
|
443
|
+
3) Tag and push:
|
|
444
|
+
```bash
|
|
445
|
+
cd layers/base
|
|
446
|
+
VERSION=$(node -p "require('./package.json').version")
|
|
447
|
+
cd ../..
|
|
448
|
+
git tag "base-v$VERSION"
|
|
449
|
+
git push origin "base-v$VERSION"
|
|
450
|
+
```
|
|
451
|
+
4) CI will publish
|
|
452
|
+
- Workflow: `.github/workflows/publish-base.yml`
|
|
453
|
+
- Auth: `NPM_TOKEN` GitHub secret
|
|
259
454
|
|
|
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.
|
|
455
|
+
Troubleshooting
|
|
456
|
+
- Version exists: bump again and retag
|
|
457
|
+
- 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,9 +30,19 @@ 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
|
+
],
|
|
36
|
+
'vue/multi-word-component-names': ['error', {
|
|
37
|
+
ignores: []
|
|
38
|
+
}],
|
|
39
|
+
'vue/component-name-in-template-casing': [
|
|
40
|
+
'error',
|
|
41
|
+
'kebab-case',
|
|
42
|
+
{ registeredComponentsOnly: false, ignores: [] }
|
|
43
|
+
],
|
|
44
|
+
'vue/component-options-name-casing': ['error', 'kebab-case'],
|
|
45
|
+
'vue/component-definition-name-casing': ['error', 'kebab-case']
|
|
31
46
|
}
|
|
32
47
|
})
|
|
33
48
|
|
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.
|
|
3
|
+
"version": "1.1.1",
|
|
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
|
-
"@
|
|
26
|
-
"
|
|
27
|
-
"nuxt": "4.1.2"
|
|
25
|
+
"@nuxtjs/i18n": "^10.2.1",
|
|
26
|
+
"nuxt": "^4.2.2"
|
|
28
27
|
},
|
|
29
28
|
"devDependencies": {
|
|
30
|
-
"@nuxt/
|
|
31
|
-
"
|
|
32
|
-
"
|
|
29
|
+
"@nuxt/eslint": "^1.10.0",
|
|
30
|
+
"@nuxt/test-utils": "^3.20.1",
|
|
31
|
+
"eslint": "^9.39.2",
|
|
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,
|