luchy 0.2.0 → 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 +253 -98
- package/dist/api/index.d.ts +44 -11
- package/dist/api/index.d.ts.map +1 -1
- package/dist/api/index.js +36 -8
- package/dist/api/schema.d.ts +862 -94
- package/dist/identity.d.ts +35 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/index.d.ts +23 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +61 -8
- package/dist/react-router.d.ts +121 -53
- package/dist/react-router.d.ts.map +1 -1
- package/dist/react-router.js +185 -48
- package/dist/script/luchy.js +62 -9
- package/dist/script/luchy.js.br +0 -0
- package/dist/script/luchy.js.gz +0 -0
- package/dist/script/luchy.min.js +62 -9
- package/dist/script/luchy.min.js.br +0 -0
- package/dist/script/luchy.min.js.gz +0 -0
- package/dist/server.d.ts +71 -39
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +114 -26
- package/package.json +17 -2
package/README.md
CHANGED
|
@@ -45,15 +45,21 @@ npm install luchy
|
|
|
45
45
|
|
|
46
46
|
## Data Attributes
|
|
47
47
|
|
|
48
|
-
| Attribute | Description
|
|
49
|
-
| ---------------------- |
|
|
50
|
-
| `data-api-key` | Your Luchy API key
|
|
51
|
-
| `data-endpoint` | Custom
|
|
52
|
-
| `data-
|
|
53
|
-
| `data-
|
|
54
|
-
| `data-auto-
|
|
55
|
-
| `data-
|
|
56
|
-
| `data-
|
|
48
|
+
| Attribute | Description | Required |
|
|
49
|
+
| ---------------------- | --------------------------------------------------------------- | -------- |
|
|
50
|
+
| `data-api-key` | Your Luchy API key | ✅ Yes |
|
|
51
|
+
| `data-endpoint` | Custom ingest endpoint | ❌ No |
|
|
52
|
+
| `data-identity` | Signed identity token (see [Identity](#identity)) | ❌ No |
|
|
53
|
+
| `data-props` | JSON object merged into the payload of every event and pageview | ❌ No |
|
|
54
|
+
| `data-auto-pageviews` | Enable automatic pageview tracking | ❌ No |
|
|
55
|
+
| `data-auto-outbound` | Enable automatic outbound link tracking | ❌ No |
|
|
56
|
+
| `data-auto-events` | Enable automatic data attribute event tracking | ❌ No |
|
|
57
|
+
| `data-hash-routing` | Enable hash routing support | ❌ No |
|
|
58
|
+
| `data-track-localhost` | Track localhost traffic | ❌ No |
|
|
59
|
+
|
|
60
|
+
`data-identity` and `data-props` are read from the script tag on **every**
|
|
61
|
+
send, not once at load, so a server-rendered tag that is re-rendered after
|
|
62
|
+
login or logout is picked up without a reload.
|
|
57
63
|
|
|
58
64
|
## Data Attribute Events
|
|
59
65
|
|
|
@@ -64,7 +70,11 @@ Track custom events without JavaScript by adding `data-luchy-event` to any HTML
|
|
|
64
70
|
<button data-luchy-event="cta-click">Sign Up</button>
|
|
65
71
|
|
|
66
72
|
<!-- With a payload (data-luchy-payload-*) -->
|
|
67
|
-
<a
|
|
73
|
+
<a
|
|
74
|
+
data-luchy-event="post-click"
|
|
75
|
+
data-luchy-payload-slug="hello-world"
|
|
76
|
+
href="/blog/hello-world"
|
|
77
|
+
>
|
|
68
78
|
Read Post
|
|
69
79
|
</a>
|
|
70
80
|
|
|
@@ -99,53 +109,121 @@ window.luchy.trackEvent('Button Click', {
|
|
|
99
109
|
page: 'home'
|
|
100
110
|
});
|
|
101
111
|
|
|
102
|
-
//
|
|
103
|
-
window.luchy.
|
|
104
|
-
|
|
105
|
-
|
|
112
|
+
// Toggle auto-tracking features
|
|
113
|
+
window.luchy.setOptions({ autoPageviews: true, hashRouting: false });
|
|
114
|
+
|
|
115
|
+
// SPAs that learn who the user is after load: attribute everything from now
|
|
116
|
+
// on to a signed token (rendered by your server), with optional props.
|
|
117
|
+
window.luchy.identify(token, { role: 'admin' });
|
|
118
|
+
|
|
119
|
+
// Back to anonymous, e.g. on logout.
|
|
120
|
+
window.luchy.reset();
|
|
106
121
|
```
|
|
107
122
|
|
|
108
|
-
|
|
123
|
+
`identify`/`reset` write through to the script tag's `data-identity` /
|
|
124
|
+
`data-props`, so the tag stays the single source of truth.
|
|
109
125
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
126
|
+
## Identity
|
|
127
|
+
|
|
128
|
+
Luchy ties hits to your users only when your server vouches for them. An
|
|
129
|
+
identity is `{ user, actor?, tenant? }` — `user` is the account being viewed
|
|
130
|
+
as, `actor` the person actually acting when they differ (impersonation),
|
|
131
|
+
`tenant` your own workspace/client id. Everything else (role, plan, …) goes in
|
|
132
|
+
props.
|
|
113
133
|
|
|
114
|
-
|
|
115
|
-
|
|
134
|
+
The server signs it with the project's secret key (`lsk_…`, from the
|
|
135
|
+
dashboard settings):
|
|
116
136
|
|
|
117
137
|
```ts
|
|
118
|
-
import {
|
|
138
|
+
import { signIdentity } from 'luchy/server';
|
|
119
139
|
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
});
|
|
140
|
+
const token = await signIdentity(
|
|
141
|
+
{ user: user.id, tenant: String(workspace.id) },
|
|
142
|
+
env.LUCHY_SECRET
|
|
143
|
+
);
|
|
144
|
+
```
|
|
126
145
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
146
|
+
The token is `base64url(JSON({ u, a?, t? })).base64url(HMAC-SHA256)`, signed
|
|
147
|
+
with WebCrypto (Workers, Node ≥ 18, Bun, Deno). Ingest verifies it; a missing,
|
|
148
|
+
unsigned or tampered token stores the hit anonymous — it is never rejected. The
|
|
149
|
+
secret never leaves your server: only the token is rendered into the page.
|
|
150
|
+
|
|
151
|
+
## Person profiles
|
|
152
|
+
|
|
153
|
+
`user` is a stable id (`usr_7Hq2mXk9`, `42`), not something to read. Profile
|
|
154
|
+
traits — a name, an email, a plan — are stored once per person with
|
|
155
|
+
`identify`, server-side, authenticated with the secret key:
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
await luchy.identify(user.id, { name: user.name, email: user.email });
|
|
133
159
|
```
|
|
134
160
|
|
|
135
|
-
|
|
136
|
-
|
|
161
|
+
Traits merge into what is stored; `null` deletes one. The dashboard labels a
|
|
162
|
+
person (and a support agent helping them) by `traits.name`, else
|
|
163
|
+
`traits.email`, else the id. The wire call is
|
|
164
|
+
`POST /api/ingest/identify` with `Authorization: Bearer <lsk_…>` and body
|
|
165
|
+
`{ user, traits }`; the public ingest key is rejected there.
|
|
166
|
+
|
|
167
|
+
## Server-Side Tracking (`luchy/server`)
|
|
137
168
|
|
|
138
|
-
|
|
169
|
+
The browser script can only see what happens in a page. A server sees what the
|
|
170
|
+
application actually _did_ — an order placed, a payment recorded, a webhook
|
|
171
|
+
processed — and those are usually the events worth having. `luchy/server`
|
|
172
|
+
carries no browser globals, so it runs in Node, Bun, Deno and Cloudflare
|
|
173
|
+
Workers:
|
|
139
174
|
|
|
140
175
|
```ts
|
|
141
|
-
|
|
176
|
+
import { createLuchy } from 'luchy/server';
|
|
177
|
+
|
|
178
|
+
const luchy = createLuchy({
|
|
179
|
+
apiKey: env.LUCHY_API_KEY,
|
|
180
|
+
// optional: signs identities; without it identities are not sent
|
|
181
|
+
secret: env.LUCHY_SECRET,
|
|
182
|
+
// optional: the ingest base of a self-hosted dashboard
|
|
183
|
+
endpoint: 'https://dash.luchy.app/api/ingest',
|
|
184
|
+
// optional: false turns every call into a no-op
|
|
185
|
+
enabled: env.APP_ENV === 'production',
|
|
186
|
+
onError: (error) => logger.warn('[luchy] event dropped', error)
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
ctx.waitUntil(
|
|
190
|
+
luchy.track(
|
|
191
|
+
{
|
|
192
|
+
name: 'order:placed',
|
|
193
|
+
pathname: '/checkout',
|
|
194
|
+
type: 'server',
|
|
195
|
+
payload: { plan: 'pro', amount: 29 },
|
|
196
|
+
// forward the end user's request so the hit joins their session
|
|
197
|
+
userAgent: request.headers.get('user-agent') ?? undefined,
|
|
198
|
+
ip: request.headers.get('cf-connecting-ip') ?? undefined,
|
|
199
|
+
country: request.cf?.country,
|
|
200
|
+
language: 'es-DO'
|
|
201
|
+
},
|
|
202
|
+
{ user: user.id, tenant: String(workspace.id) }
|
|
203
|
+
)
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
await luchy.pageview({ pathname: '/pricing', payload: { plan: 'pro' } });
|
|
142
207
|
```
|
|
143
208
|
|
|
209
|
+
- `identify(user, traits)` stores profile traits (see
|
|
210
|
+
[Person profiles](#person-profiles)). It needs `secret`; without it the call
|
|
211
|
+
is a no-op and `onError` hears about it once.
|
|
212
|
+
- `track(event, identity?)` and `pageview(pageview, identity?)` take an
|
|
213
|
+
identity object (signed with `secret`) or an already-signed token string.
|
|
214
|
+
- **Without `secret`, an identity object is not sent at all** — ingest would
|
|
215
|
+
drop it unsigned anyway, so the hit is plainly anonymous.
|
|
216
|
+
- `ip` is only used to compute the visitor hash and is never stored. Without
|
|
217
|
+
it, ingest uses the sender's own `cf-connecting-ip` — which, for a server,
|
|
218
|
+
is the server.
|
|
219
|
+
- Nothing ever rejects — analytics must not break the request it is
|
|
220
|
+
describing. Pass `onError` if you want failures in your logs.
|
|
221
|
+
|
|
144
222
|
## API client (`luchy/api`)
|
|
145
223
|
|
|
146
|
-
`
|
|
224
|
+
`createLuchy` covers the two calls most apps make. `luchy/api`
|
|
147
225
|
is the whole API: a typed client generated from the dashboard's own OpenAPI
|
|
148
|
-
document, with tracking
|
|
226
|
+
document, with tracking _and_ reading on it. It has no DOM globals and no node
|
|
149
227
|
built-ins, so the same import works on a server and in a browser.
|
|
150
228
|
|
|
151
229
|
```ts
|
|
@@ -166,10 +244,22 @@ await luchy.trackEvent({
|
|
|
166
244
|
});
|
|
167
245
|
|
|
168
246
|
// Reads do — a chart with silently missing numbers is worse than an error.
|
|
169
|
-
const {
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
247
|
+
const { current, delta } = await luchy.query({
|
|
248
|
+
from: '2025-01-01T00:00:00Z',
|
|
249
|
+
to: '2025-02-01T00:00:00Z',
|
|
250
|
+
metrics: ['events', 'users'],
|
|
251
|
+
breakdown: 'tenant', // or page, user, props.<key>, hour, weekday, …
|
|
252
|
+
filters: [['props.role', 'eq', 'admin']],
|
|
253
|
+
compare: 'previous_period'
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// Anything the engine can't express: one read-only SELECT/WITH over the whole
|
|
257
|
+
// database (not scoped to a project). The tables are documented in the
|
|
258
|
+
// method's description: https://dash.luchy.app/llms.txt
|
|
259
|
+
const { rows } = await luchy.sql({
|
|
260
|
+
query:
|
|
261
|
+
'SELECT tenant, COUNT(*) AS n FROM events WHERE project_id = ? GROUP BY tenant',
|
|
262
|
+
params: ['project_id']
|
|
173
263
|
});
|
|
174
264
|
```
|
|
175
265
|
|
|
@@ -184,13 +274,18 @@ const { data, error } = await luchy.api.GET('/health');
|
|
|
184
274
|
|
|
185
275
|
The request and response types come from the document too, so they are worth
|
|
186
276
|
importing rather than restating: `EventInput`, `PageviewInput`,
|
|
187
|
-
`IngestSuccess`, `QueryRequest`, `QueryResponse`,
|
|
188
|
-
`components`.
|
|
277
|
+
`IngestSuccess`, `QueryRequest`, `QueryResponse`, `SqlRequest`, `SqlResponse`,
|
|
278
|
+
`MethodError`, plus the raw `paths` and `components`.
|
|
279
|
+
|
|
280
|
+
Reads use a query key (`wak_…`) minted in the dashboard under Keys. They call
|
|
281
|
+
the dashboard's analytics methods (`POST /api/analytics.query`,
|
|
282
|
+
`/api/analytics.sql`, …), the same ones its MCP server at
|
|
283
|
+
`https://dash.luchy.app/mcp` exposes to agents.
|
|
189
284
|
|
|
190
285
|
### Where the types come from
|
|
191
286
|
|
|
192
287
|
```
|
|
193
|
-
apps/dash zod
|
|
288
|
+
apps/dash ingest routes (zod) + kit methods (app/kit)
|
|
194
289
|
→ bun run openapi:emit (in apps/dash)
|
|
195
290
|
→ packages/tracker/openapi.json
|
|
196
291
|
→ bun run generate (in packages/tracker)
|
|
@@ -200,67 +295,127 @@ apps/dash zod route schemas
|
|
|
200
295
|
`openapi.json` is checked in: it is the wire contract, so a change to the API's
|
|
201
296
|
shape shows up as a reviewable diff in the commit that caused it, and the
|
|
202
297
|
client can be regenerated without a dashboard running anywhere. `schema.d.ts`
|
|
203
|
-
is generated too — never edit either by hand. Change the
|
|
298
|
+
is generated too — never edit either by hand. Change the schemas in
|
|
204
299
|
`apps/dash`, re-run both steps, commit the result.
|
|
205
300
|
|
|
206
|
-
## React Router
|
|
301
|
+
## React Router (`luchy/react-router`)
|
|
302
|
+
|
|
303
|
+
For React Router v7.9+ apps with middleware. Three pieces:
|
|
304
|
+
|
|
305
|
+
- `luchyMiddleware` — a root route middleware. Every mutation React Router
|
|
306
|
+
serves becomes a server event, attributed to the request's identity.
|
|
307
|
+
- `getLuchy(context)` — for the root loader: the browser script's config, with
|
|
308
|
+
the identity already signed.
|
|
309
|
+
- `<LuchyScript {...luchy} />` — renders the browser script with
|
|
310
|
+
`data-identity` / `data-props`, so server events and browser pageviews land
|
|
311
|
+
in the same session.
|
|
207
312
|
|
|
208
313
|
A React Router app already tells you what it did — in the request. Every
|
|
209
314
|
console mutation is a form POST whose `intent` field names it (`rotate`,
|
|
210
315
|
`invite-member`, `create-api-key`, …), every JSON API mutation is
|
|
211
316
|
discriminated by its method, and every auth verb has a path that names the
|
|
212
|
-
operation. So the event
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
317
|
+
operation. So the event is _derived_ from the request instead of emitted by
|
|
318
|
+
hand from route modules: zero per-route code, and a new intent is tracked the
|
|
319
|
+
day it is written.
|
|
320
|
+
|
|
321
|
+
```tsx
|
|
322
|
+
// app/root.tsx
|
|
323
|
+
import { getLuchy, luchyMiddleware, LuchyScript } from 'luchy/react-router';
|
|
324
|
+
|
|
325
|
+
export const middleware: Route.MiddlewareFunction[] = [
|
|
326
|
+
luchyMiddleware({
|
|
327
|
+
apiKey: env.LUCHY_API_KEY,
|
|
328
|
+
secret: env.LUCHY_SECRET,
|
|
329
|
+
enabled: env.APP_ENV === 'production',
|
|
330
|
+
// How to keep the event alive after the response. Nothing here imports
|
|
331
|
+
// `cloudflare:workers`; hand it whatever your runtime has.
|
|
332
|
+
waitUntil:
|
|
333
|
+
({ context }) =>
|
|
334
|
+
(promise) =>
|
|
335
|
+
context.get(cfContext).ctx.waitUntil(promise),
|
|
336
|
+
identity: async ({ context }) => {
|
|
337
|
+
const s = await context.get(appContext).getMaybeSessionContext();
|
|
338
|
+
if (!s) {
|
|
339
|
+
return undefined;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return {
|
|
343
|
+
user: s.user.id,
|
|
344
|
+
tenant: String(s.workspaceId),
|
|
345
|
+
props: { role: s.role },
|
|
346
|
+
traits: { name: s.user.name, email: s.user.email }
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
})
|
|
350
|
+
];
|
|
351
|
+
|
|
352
|
+
export async function loader({ context }: Route.LoaderArgs) {
|
|
353
|
+
return { luchy: await getLuchy(context) };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export function Layout({ children }: { children: React.ReactNode }) {
|
|
357
|
+
const data = useRouteLoaderData<typeof loader>('root');
|
|
358
|
+
|
|
359
|
+
return (
|
|
360
|
+
<html lang="en">
|
|
361
|
+
<head>
|
|
362
|
+
{/* … */}
|
|
363
|
+
{data ? <LuchyScript {...data.luchy} /> : null}
|
|
364
|
+
</head>
|
|
365
|
+
<body>{children}</body>
|
|
366
|
+
</html>
|
|
367
|
+
);
|
|
368
|
+
}
|
|
235
369
|
```
|
|
236
370
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
`
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
371
|
+
How it behaves:
|
|
372
|
+
|
|
373
|
+
- Only mutations (`POST`/`PUT`/`PATCH`/`DELETE`) produce events. Reads are the
|
|
374
|
+
browser script's job.
|
|
375
|
+
- Events are named `route:intent` — `team/:id:invite-member`,
|
|
376
|
+
`applications:create` — with ids collapsed to `:id`, React Router's
|
|
377
|
+
single-fetch `.data` suffix stripped (so a hydrated and a non-hydrated
|
|
378
|
+
submission report the same name), and `/` mapped to `home`.
|
|
379
|
+
- The body is cloned before `next()` only when it can carry an `intent`
|
|
380
|
+
(urlencoded); the action still reads the original.
|
|
381
|
+
- Redirects and responses thrown by actions are tracked with their real status
|
|
382
|
+
and re-thrown untouched.
|
|
383
|
+
- The payload is the identity's `props` plus `status` (always the response
|
|
384
|
+
status — props cannot override it). The end user's `cf-connecting-ip`,
|
|
385
|
+
country (`request.cf.country` or `cf-ipcountry`), first `accept-language` tag
|
|
386
|
+
and user agent are forwarded.
|
|
387
|
+
- `identity` is resolved lazily and at most once per request: only when an
|
|
388
|
+
event ships or `getLuchy` asks. A rejection is reported to `onError` and the
|
|
389
|
+
request is treated as anonymous.
|
|
390
|
+
- `traits`, when the resolver returns them, are sent via `identify` through
|
|
391
|
+
the same `waitUntil` — at most once per isolate per hour for the same user
|
|
392
|
+
and traits (a small in-memory map, bounded at 1000 entries). Changed traits
|
|
393
|
+
go out on the next request. Needs `secret`; traits never reach the page.
|
|
394
|
+
- `<LuchyScript>` must be server-rendered — React does not execute `<script>`
|
|
395
|
+
elements it creates on the client. Re-renders after login/logout update its
|
|
396
|
+
attributes, and the script picks them up on the next send.
|
|
397
|
+
- Only requests React Router routes are seen. Anything your Worker answers
|
|
398
|
+
before handing over to React Router is invisible to the middleware.
|
|
399
|
+
|
|
400
|
+
| Option | Default | What it does |
|
|
401
|
+
| --------------------- | ----------------- | -------------------------------------------------------------------------------------------- |
|
|
402
|
+
| `apiKey` | — | Luchy API key. The public ingest key is fine. |
|
|
403
|
+
| `secret` | — | Secret key. Without it no identity or traits are sent (props still are). |
|
|
404
|
+
| `endpoint` | hosted ingest | Ingest base, without a trailing slash. Also what `getLuchy` hands the script. |
|
|
405
|
+
| `enabled` | `true` | When false no events are produced — no clone, no network. `getLuchy` keeps working. |
|
|
406
|
+
| `waitUntil` | — | `(args) => (promise) => void`. Called only for requests that produce an event. |
|
|
407
|
+
| `identity` | — | `(args) => ({ user, actor?, tenant?, props?, traits? }) \| undefined`, sync or async. |
|
|
408
|
+
| `ignorePrefixes` | `['/__manifest']` | Raw-pathname prefixes to drop. Yours are added to the default. |
|
|
409
|
+
| `ignoreRouteSuffixes` | `[]` | Normalized-route suffixes to drop, e.g. `/user-keys/validate`. |
|
|
410
|
+
| `ignoreEvents` | `[]` | Fully-formed event names to drop, e.g. `notifications:markRead`. |
|
|
411
|
+
| `trackFailures` | `false` | When true, 4xx/5xx are tracked too (tell them apart by `status`). |
|
|
412
|
+
| `methodSuffix` | `false` | When true, an intent-less mutation is `route:post` / `route:delete` instead of bare `route`. |
|
|
413
|
+
| `onError` | — | The only way to see failures. |
|
|
414
|
+
|
|
415
|
+
`<LuchyScript>` also takes `src` (defaults to the CDN build) and `nonce`.
|
|
416
|
+
|
|
417
|
+
The naming pieces are exported on their own too — `isMutatingMethod`,
|
|
418
|
+
`carriesIntent`, `normalizeRoute`, `serverEventName`.
|
|
264
419
|
|
|
265
420
|
## Development
|
|
266
421
|
|
package/dist/api/index.d.ts
CHANGED
|
@@ -18,12 +18,40 @@ export type { components, paths };
|
|
|
18
18
|
export type EventInput = components['schemas']['EventInput'];
|
|
19
19
|
/** The body accepted by `POST /ingest/pageview`. */
|
|
20
20
|
export type PageviewInput = components['schemas']['PageviewInput'];
|
|
21
|
+
/** The body accepted by `POST /ingest/identify` (secret key only). */
|
|
22
|
+
export type IdentifyInput = components['schemas']['IdentifyInput'];
|
|
21
23
|
/** What both ingest endpoints answer with on success. */
|
|
22
24
|
export type IngestSuccess = components['schemas']['SuccessResponse'];
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
type Body<P extends keyof paths> = paths[P] extends {
|
|
26
|
+
post: {
|
|
27
|
+
requestBody?: {
|
|
28
|
+
content: {
|
|
29
|
+
'application/json': infer B;
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
} ? B : never;
|
|
34
|
+
type Result<P extends keyof paths> = paths[P] extends {
|
|
35
|
+
post: {
|
|
36
|
+
responses: {
|
|
37
|
+
200: {
|
|
38
|
+
content: {
|
|
39
|
+
'application/json': infer R;
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
} ? R : never;
|
|
45
|
+
/** The body accepted by `POST /analytics.query`. */
|
|
46
|
+
export type QueryRequest = Body<'/analytics.query'>;
|
|
47
|
+
/** The result of a successful `POST /analytics.query`. */
|
|
48
|
+
export type QueryResponse = Result<'/analytics.query'>;
|
|
49
|
+
/** The body accepted by `POST /analytics.sql`. */
|
|
50
|
+
export type SqlRequest = Body<'/analytics.sql'>;
|
|
51
|
+
/** The result of a successful `POST /analytics.sql`. */
|
|
52
|
+
export type SqlResponse = Result<'/analytics.sql'>;
|
|
53
|
+
/** A kit method's error body (`{ error }`, plus `fields` for invalid input). */
|
|
54
|
+
export type MethodError = components['schemas']['InvalidInput'];
|
|
27
55
|
/** The `GET /health` body. */
|
|
28
56
|
export type HealthResponse = components['schemas']['HealthResponse'];
|
|
29
57
|
/** A 400 from any endpoint. */
|
|
@@ -53,21 +81,24 @@ export type LuchyClient = {
|
|
|
53
81
|
/**
|
|
54
82
|
* The underlying `openapi-fetch` client, pre-authenticated. Use it for
|
|
55
83
|
* anything the convenience methods below do not cover; it is typed against
|
|
56
|
-
* the full document, so `api.POST('/query', { body })` is checked
|
|
84
|
+
* the full document, so `api.POST('/analytics.query', { body })` is checked
|
|
85
|
+
* end to end.
|
|
57
86
|
*/
|
|
58
87
|
api: LuchyApi;
|
|
59
88
|
trackEvent(event: EventInput): Promise<IngestSuccess | null>;
|
|
60
89
|
trackPageview(pageview: PageviewInput): Promise<IngestSuccess | null>;
|
|
61
90
|
query(request: QueryRequest): Promise<QueryResponse>;
|
|
91
|
+
sql(request: SqlRequest): Promise<SqlResponse>;
|
|
62
92
|
health(): Promise<HealthResponse>;
|
|
63
93
|
};
|
|
64
94
|
/**
|
|
65
95
|
* Thrown by the endpoints where failing loudly is the right answer — reads
|
|
66
|
-
* (`query`, `health`), as opposed to the fire-and-forget
|
|
96
|
+
* (`query`, `sql`, `health`), as opposed to the fire-and-forget
|
|
97
|
+
* tracking calls.
|
|
67
98
|
*
|
|
68
99
|
* ```ts
|
|
69
100
|
* try {
|
|
70
|
-
* await luchy.
|
|
101
|
+
* await luchy.sql({ query: 'SELECT COUNT(*) AS n FROM events' });
|
|
71
102
|
* } catch (error) {
|
|
72
103
|
* if (error instanceof LuchyApiError) {
|
|
73
104
|
* console.error(error.status, error.body);
|
|
@@ -79,8 +110,8 @@ export declare class LuchyApiError extends Error {
|
|
|
79
110
|
/** The HTTP status. Transport failures propagate as the runtime's own error, not this one. */
|
|
80
111
|
readonly status: number;
|
|
81
112
|
/** The parsed error body, when the API sent one. */
|
|
82
|
-
readonly body: ValidationError | ServerError | undefined;
|
|
83
|
-
constructor(message: string, status: number, body?: ValidationError | ServerError);
|
|
113
|
+
readonly body: ValidationError | ServerError | MethodError | undefined;
|
|
114
|
+
constructor(message: string, status: number, body?: ValidationError | ServerError | MethodError);
|
|
84
115
|
}
|
|
85
116
|
/**
|
|
86
117
|
* Creates a Luchy API client.
|
|
@@ -103,8 +134,10 @@ export declare class LuchyApiError extends Error {
|
|
|
103
134
|
* });
|
|
104
135
|
*
|
|
105
136
|
* const stats = await luchy.query({
|
|
106
|
-
*
|
|
107
|
-
*
|
|
137
|
+
* from: '2025-01-01T00:00:00Z',
|
|
138
|
+
* to: '2025-02-01T00:00:00Z',
|
|
139
|
+
* metrics: ['pageviews', 'visitors'],
|
|
140
|
+
* compare: 'previous_period'
|
|
108
141
|
* });
|
|
109
142
|
* ```
|
|
110
143
|
*/
|
package/dist/api/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/api/index.ts"],"names":[],"mappings":"AAAA,OAAqB,EAAE,KAAK,MAAM,EAAE,MAAM,eAAe,CAAC;AAC1D,OAAO,KAAK,EAAE,UAAU,EAAE,KAAK,EAAE,MAAM,UAAU,CAAC;AAElD;;;;;;;;;;;;GAYG;AAEH,YAAY,EAAE,UAAU,EAAE,KAAK,EAAE,CAAC;AAElC,iDAAiD;AACjD,MAAM,MAAM,UAAU,GAAG,UAAU,CAAC,SAAS,CAAC,CAAC,YAAY,CAAC,CAAC;AAC7D,oDAAoD;AACpD,MAAM,MAAM,aAAa,GAAG,UAAU,CAAC,SAAS,CAAC,CAAC,eAAe,CAAC,CAAC;AACnE,yDAAyD;AACzD,MAAM,MAAM,aAAa,GAAG,UAAU,CAAC,SAAS,CAAC,CAAC,iBAAiB,CAAC,CAAC;AACrE,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/api/index.ts"],"names":[],"mappings":"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"}
|
package/dist/api/index.js
CHANGED
|
@@ -75,20 +75,22 @@ function createLuchyClient(options) {
|
|
|
75
75
|
);
|
|
76
76
|
},
|
|
77
77
|
/**
|
|
78
|
-
* Runs an analytics query. Unlike the tracking
|
|
79
|
-
* `LuchyApiError` on a non-2xx, because a caller
|
|
80
|
-
* know the numbers are missing.
|
|
78
|
+
* Runs an analytics query against the key's project. Unlike the tracking
|
|
79
|
+
* calls this throws `LuchyApiError` on a non-2xx, because a caller
|
|
80
|
+
* rendering a chart needs to know the numbers are missing.
|
|
81
81
|
*
|
|
82
82
|
* ```ts
|
|
83
|
-
* const {
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
83
|
+
* const { current } = await luchy.query({
|
|
84
|
+
* from: '2025-01-01T00:00:00Z',
|
|
85
|
+
* to: '2025-02-01T00:00:00Z',
|
|
86
|
+
* metrics: ['events', 'users'],
|
|
87
|
+
* breakdown: 'tenant',
|
|
88
|
+
* filters: [['props.role', 'eq', 'admin']]
|
|
87
89
|
* });
|
|
88
90
|
* ```
|
|
89
91
|
*/
|
|
90
92
|
async query(request) {
|
|
91
|
-
const { data, error, response } = await api.POST("/query", {
|
|
93
|
+
const { data, error, response } = await api.POST("/analytics.query", {
|
|
92
94
|
body: request
|
|
93
95
|
});
|
|
94
96
|
if (error || !data) {
|
|
@@ -100,6 +102,32 @@ function createLuchyClient(options) {
|
|
|
100
102
|
}
|
|
101
103
|
return data;
|
|
102
104
|
},
|
|
105
|
+
/**
|
|
106
|
+
* Runs one read-only `SELECT`/`WITH` statement over the whole database
|
|
107
|
+
* (not scoped to a project — filter on `project_id`). The schema is in
|
|
108
|
+
* the method's description: `GET /llms.txt` or `/openapi.json` on the dash.
|
|
109
|
+
* Throws `LuchyApiError` on a non-2xx, including rejected statements (400).
|
|
110
|
+
*
|
|
111
|
+
* ```ts
|
|
112
|
+
* const { rows } = await luchy.sql({
|
|
113
|
+
* query: 'SELECT tenant, COUNT(*) AS n FROM events WHERE project_id = ? GROUP BY tenant',
|
|
114
|
+
* params: ['project_1']
|
|
115
|
+
* });
|
|
116
|
+
* ```
|
|
117
|
+
*/
|
|
118
|
+
async sql(request) {
|
|
119
|
+
const { data, error, response } = await api.POST("/analytics.sql", {
|
|
120
|
+
body: request
|
|
121
|
+
});
|
|
122
|
+
if (error || !data) {
|
|
123
|
+
throw new LuchyApiError(
|
|
124
|
+
`Luchy sql failed with ${response.status}`,
|
|
125
|
+
response.status,
|
|
126
|
+
error
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return data;
|
|
130
|
+
},
|
|
103
131
|
/**
|
|
104
132
|
* Pings the API. Throws `LuchyApiError` if it is not healthy.
|
|
105
133
|
*
|