luchy 0.2.0 → 1.0.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 +245 -95
- package/dist/api/index.d.ts +21 -9
- package/dist/api/index.d.ts.map +1 -1
- package/dist/api/index.js +46 -8
- package/dist/api/schema.d.ts +367 -79
- 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.
|
|
137
166
|
|
|
138
|
-
|
|
167
|
+
## Server-Side Tracking (`luchy/server`)
|
|
168
|
+
|
|
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,21 @@ 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). `luchy.schema()` documents the tables.
|
|
258
|
+
const { rows } = await luchy.sql({
|
|
259
|
+
query:
|
|
260
|
+
'SELECT tenant, COUNT(*) AS n FROM events WHERE project_id = ? GROUP BY tenant',
|
|
261
|
+
params: ['project_id']
|
|
173
262
|
});
|
|
174
263
|
```
|
|
175
264
|
|
|
@@ -184,7 +273,8 @@ const { data, error } = await luchy.api.GET('/health');
|
|
|
184
273
|
|
|
185
274
|
The request and response types come from the document too, so they are worth
|
|
186
275
|
importing rather than restating: `EventInput`, `PageviewInput`,
|
|
187
|
-
`IngestSuccess`, `QueryRequest`, `QueryResponse`,
|
|
276
|
+
`IngestSuccess`, `QueryRequest`, `QueryResponse`, `SqlRequest`, `SqlResponse`,
|
|
277
|
+
plus the raw `paths` and
|
|
188
278
|
`components`.
|
|
189
279
|
|
|
190
280
|
### Where the types come from
|
|
@@ -203,64 +293,124 @@ client can be regenerated without a dashboard running anywhere. `schema.d.ts`
|
|
|
203
293
|
is generated too — never edit either by hand. Change the zod schemas in
|
|
204
294
|
`apps/dash`, re-run both steps, commit the result.
|
|
205
295
|
|
|
206
|
-
## React Router
|
|
296
|
+
## React Router (`luchy/react-router`)
|
|
297
|
+
|
|
298
|
+
For React Router v7.9+ apps with middleware. Three pieces:
|
|
299
|
+
|
|
300
|
+
- `luchyMiddleware` — a root route middleware. Every mutation React Router
|
|
301
|
+
serves becomes a server event, attributed to the request's identity.
|
|
302
|
+
- `getLuchy(context)` — for the root loader: the browser script's config, with
|
|
303
|
+
the identity already signed.
|
|
304
|
+
- `<LuchyScript {...luchy} />` — renders the browser script with
|
|
305
|
+
`data-identity` / `data-props`, so server events and browser pageviews land
|
|
306
|
+
in the same session.
|
|
207
307
|
|
|
208
308
|
A React Router app already tells you what it did — in the request. Every
|
|
209
309
|
console mutation is a form POST whose `intent` field names it (`rotate`,
|
|
210
310
|
`invite-member`, `create-api-key`, …), every JSON API mutation is
|
|
211
311
|
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
|
-
|
|
312
|
+
operation. So the event is _derived_ from the request instead of emitted by
|
|
313
|
+
hand from route modules: zero per-route code, and a new intent is tracked the
|
|
314
|
+
day it is written.
|
|
315
|
+
|
|
316
|
+
```tsx
|
|
317
|
+
// app/root.tsx
|
|
318
|
+
import { getLuchy, luchyMiddleware, LuchyScript } from 'luchy/react-router';
|
|
319
|
+
|
|
320
|
+
export const middleware: Route.MiddlewareFunction[] = [
|
|
321
|
+
luchyMiddleware({
|
|
322
|
+
apiKey: env.LUCHY_API_KEY,
|
|
323
|
+
secret: env.LUCHY_SECRET,
|
|
324
|
+
enabled: env.APP_ENV === 'production',
|
|
325
|
+
// How to keep the event alive after the response. Nothing here imports
|
|
326
|
+
// `cloudflare:workers`; hand it whatever your runtime has.
|
|
327
|
+
waitUntil:
|
|
328
|
+
({ context }) =>
|
|
329
|
+
(promise) =>
|
|
330
|
+
context.get(cfContext).ctx.waitUntil(promise),
|
|
331
|
+
identity: async ({ context }) => {
|
|
332
|
+
const s = await context.get(appContext).getMaybeSessionContext();
|
|
333
|
+
if (!s) {
|
|
334
|
+
return undefined;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
user: s.user.id,
|
|
339
|
+
tenant: String(s.workspaceId),
|
|
340
|
+
props: { role: s.role },
|
|
341
|
+
traits: { name: s.user.name, email: s.user.email }
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
})
|
|
345
|
+
];
|
|
346
|
+
|
|
347
|
+
export async function loader({ context }: Route.LoaderArgs) {
|
|
348
|
+
return { luchy: await getLuchy(context) };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function Layout({ children }: { children: React.ReactNode }) {
|
|
352
|
+
const data = useRouteLoaderData<typeof loader>('root');
|
|
353
|
+
|
|
354
|
+
return (
|
|
355
|
+
<html lang="en">
|
|
356
|
+
<head>
|
|
357
|
+
{/* … */}
|
|
358
|
+
{data ? <LuchyScript {...data.luchy} /> : null}
|
|
359
|
+
</head>
|
|
360
|
+
<body>{children}</body>
|
|
361
|
+
</html>
|
|
362
|
+
);
|
|
363
|
+
}
|
|
235
364
|
```
|
|
236
365
|
|
|
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
|
-
|
|
366
|
+
How it behaves:
|
|
367
|
+
|
|
368
|
+
- Only mutations (`POST`/`PUT`/`PATCH`/`DELETE`) produce events. Reads are the
|
|
369
|
+
browser script's job.
|
|
370
|
+
- Events are named `route:intent` — `team/:id:invite-member`,
|
|
371
|
+
`applications:create` — with ids collapsed to `:id`, React Router's
|
|
372
|
+
single-fetch `.data` suffix stripped (so a hydrated and a non-hydrated
|
|
373
|
+
submission report the same name), and `/` mapped to `home`.
|
|
374
|
+
- The body is cloned before `next()` only when it can carry an `intent`
|
|
375
|
+
(urlencoded); the action still reads the original.
|
|
376
|
+
- Redirects and responses thrown by actions are tracked with their real status
|
|
377
|
+
and re-thrown untouched.
|
|
378
|
+
- The payload is the identity's `props` plus `status` (always the response
|
|
379
|
+
status — props cannot override it). The end user's `cf-connecting-ip`,
|
|
380
|
+
country (`request.cf.country` or `cf-ipcountry`), first `accept-language` tag
|
|
381
|
+
and user agent are forwarded.
|
|
382
|
+
- `identity` is resolved lazily and at most once per request: only when an
|
|
383
|
+
event ships or `getLuchy` asks. A rejection is reported to `onError` and the
|
|
384
|
+
request is treated as anonymous.
|
|
385
|
+
- `traits`, when the resolver returns them, are sent via `identify` through
|
|
386
|
+
the same `waitUntil` — at most once per isolate per hour for the same user
|
|
387
|
+
and traits (a small in-memory map, bounded at 1000 entries). Changed traits
|
|
388
|
+
go out on the next request. Needs `secret`; traits never reach the page.
|
|
389
|
+
- `<LuchyScript>` must be server-rendered — React does not execute `<script>`
|
|
390
|
+
elements it creates on the client. Re-renders after login/logout update its
|
|
391
|
+
attributes, and the script picks them up on the next send.
|
|
392
|
+
- Only requests React Router routes are seen. Anything your Worker answers
|
|
393
|
+
before handing over to React Router is invisible to the middleware.
|
|
394
|
+
|
|
395
|
+
| Option | Default | What it does |
|
|
396
|
+
| --------------------- | ----------------- | -------------------------------------------------------------------------------------------- |
|
|
397
|
+
| `apiKey` | — | Luchy API key. The public ingest key is fine. |
|
|
398
|
+
| `secret` | — | Secret key. Without it no identity or traits are sent (props still are). |
|
|
399
|
+
| `endpoint` | hosted ingest | Ingest base, without a trailing slash. Also what `getLuchy` hands the script. |
|
|
400
|
+
| `enabled` | `true` | When false no events are produced — no clone, no network. `getLuchy` keeps working. |
|
|
401
|
+
| `waitUntil` | — | `(args) => (promise) => void`. Called only for requests that produce an event. |
|
|
402
|
+
| `identity` | — | `(args) => ({ user, actor?, tenant?, props?, traits? }) \| undefined`, sync or async. |
|
|
403
|
+
| `ignorePrefixes` | `['/__manifest']` | Raw-pathname prefixes to drop. Yours are added to the default. |
|
|
404
|
+
| `ignoreRouteSuffixes` | `[]` | Normalized-route suffixes to drop, e.g. `/user-keys/validate`. |
|
|
405
|
+
| `ignoreEvents` | `[]` | Fully-formed event names to drop, e.g. `notifications:markRead`. |
|
|
406
|
+
| `trackFailures` | `false` | When true, 4xx/5xx are tracked too (tell them apart by `status`). |
|
|
407
|
+
| `methodSuffix` | `false` | When true, an intent-less mutation is `route:post` / `route:delete` instead of bare `route`. |
|
|
408
|
+
| `onError` | — | The only way to see failures. |
|
|
409
|
+
|
|
410
|
+
`<LuchyScript>` also takes `src` (defaults to the CDN build) and `nonce`.
|
|
411
|
+
|
|
412
|
+
The naming pieces are exported on their own too — `isMutatingMethod`,
|
|
413
|
+
`carriesIntent`, `normalizeRoute`, `serverEventName`.
|
|
264
414
|
|
|
265
415
|
## Development
|
|
266
416
|
|
package/dist/api/index.d.ts
CHANGED
|
@@ -18,12 +18,18 @@ 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
|
-
/** The body accepted by `POST /query`. */
|
|
24
|
-
export type QueryRequest = components['schemas']['
|
|
25
|
-
/** The result of a successful `POST /query`. */
|
|
26
|
-
export type QueryResponse = components['schemas']['
|
|
25
|
+
/** The body accepted by `POST /analytics/query`. */
|
|
26
|
+
export type QueryRequest = components['schemas']['AnalyticsQueryRequest'];
|
|
27
|
+
/** The result of a successful `POST /analytics/query`. */
|
|
28
|
+
export type QueryResponse = components['schemas']['AnalyticsQueryResponse'];
|
|
29
|
+
/** The body accepted by `POST /analytics/sql`. */
|
|
30
|
+
export type SqlRequest = components['schemas']['SqlRequest'];
|
|
31
|
+
/** The result of a successful `POST /analytics/sql`. */
|
|
32
|
+
export type SqlResponse = components['schemas']['SqlResponse'];
|
|
27
33
|
/** The `GET /health` body. */
|
|
28
34
|
export type HealthResponse = components['schemas']['HealthResponse'];
|
|
29
35
|
/** A 400 from any endpoint. */
|
|
@@ -53,21 +59,25 @@ export type LuchyClient = {
|
|
|
53
59
|
/**
|
|
54
60
|
* The underlying `openapi-fetch` client, pre-authenticated. Use it for
|
|
55
61
|
* anything the convenience methods below do not cover; it is typed against
|
|
56
|
-
* the full document, so `api.POST('/query', { body })` is checked
|
|
62
|
+
* the full document, so `api.POST('/analytics/query', { body })` is checked
|
|
63
|
+
* end to end.
|
|
57
64
|
*/
|
|
58
65
|
api: LuchyApi;
|
|
59
66
|
trackEvent(event: EventInput): Promise<IngestSuccess | null>;
|
|
60
67
|
trackPageview(pageview: PageviewInput): Promise<IngestSuccess | null>;
|
|
61
68
|
query(request: QueryRequest): Promise<QueryResponse>;
|
|
69
|
+
sql(request: SqlRequest): Promise<SqlResponse>;
|
|
70
|
+
schema(): Promise<string>;
|
|
62
71
|
health(): Promise<HealthResponse>;
|
|
63
72
|
};
|
|
64
73
|
/**
|
|
65
74
|
* Thrown by the endpoints where failing loudly is the right answer — reads
|
|
66
|
-
* (`query`, `health`), as opposed to the fire-and-forget
|
|
75
|
+
* (`query`, `sql`, `schema`, `health`), as opposed to the fire-and-forget
|
|
76
|
+
* tracking calls.
|
|
67
77
|
*
|
|
68
78
|
* ```ts
|
|
69
79
|
* try {
|
|
70
|
-
* await luchy.
|
|
80
|
+
* await luchy.sql({ query: 'SELECT COUNT(*) AS n FROM events' });
|
|
71
81
|
* } catch (error) {
|
|
72
82
|
* if (error instanceof LuchyApiError) {
|
|
73
83
|
* console.error(error.status, error.body);
|
|
@@ -103,8 +113,10 @@ export declare class LuchyApiError extends Error {
|
|
|
103
113
|
* });
|
|
104
114
|
*
|
|
105
115
|
* const stats = await luchy.query({
|
|
106
|
-
*
|
|
107
|
-
*
|
|
116
|
+
* from: '2025-01-01T00:00:00Z',
|
|
117
|
+
* to: '2025-02-01T00:00:00Z',
|
|
118
|
+
* metrics: ['pageviews', 'visitors'],
|
|
119
|
+
* compare: 'previous_period'
|
|
108
120
|
* });
|
|
109
121
|
* ```
|
|
110
122
|
*/
|
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,42 @@ 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`). Throws
|
|
108
|
+
* `LuchyApiError` on a non-2xx, including rejected statements (400).
|
|
109
|
+
*
|
|
110
|
+
* ```ts
|
|
111
|
+
* const { rows } = await luchy.sql({
|
|
112
|
+
* query: 'SELECT tenant, COUNT(*) AS n FROM events WHERE project_id = ? GROUP BY tenant',
|
|
113
|
+
* params: ['project_1']
|
|
114
|
+
* });
|
|
115
|
+
* ```
|
|
116
|
+
*/
|
|
117
|
+
async sql(request) {
|
|
118
|
+
const { data, error, response } = await api.POST("/analytics/sql", {
|
|
119
|
+
body: request
|
|
120
|
+
});
|
|
121
|
+
if (error || !data) {
|
|
122
|
+
throw new LuchyApiError(
|
|
123
|
+
`Luchy sql failed with ${response.status}`,
|
|
124
|
+
response.status,
|
|
125
|
+
error
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
return data;
|
|
129
|
+
},
|
|
130
|
+
/** The tables, columns and conventions to write `sql` queries against. */
|
|
131
|
+
async schema() {
|
|
132
|
+
const { data, response } = await api.GET("/analytics/schema");
|
|
133
|
+
if (!data) {
|
|
134
|
+
throw new LuchyApiError(
|
|
135
|
+
`Luchy schema failed with ${response.status}`,
|
|
136
|
+
response.status
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
return data.doc;
|
|
140
|
+
},
|
|
103
141
|
/**
|
|
104
142
|
* Pings the API. Throws `LuchyApiError` if it is not healthy.
|
|
105
143
|
*
|