@e-llm-studio/appmod-usage-tracker
v2.2.1
Published
Zero-config, local-first engagement tracking: measures how long each application is open and how much of that time the user is engaged.
Readme
@e-llm-studio/appmod-usage-tracker
Zero-config, local-first engagement tracking for browser applications. It measures how long each application is open and how much of that time the user is actually engaged, aggregates it into one record per application per minute, and delivers those records to the analytics service.
Package:
@e-llm-studio/appmod-usage-trackerPart of: the e-LLM Studio library collection Environment: browser only — requireswindow,documentand IndexedDB
Table of contents
- What it measures
- Installation
- Quick start
- Configuration
- API
- What gets sent
- Behaviour worth knowing
- Upgrading
- Development
- Further documentation
1. What it measures
Every minute, for each application that was on screen, one record is produced:
| Field | Meaning |
| :---- | :---- |
| openSeconds | 0–60 — seconds of that minute the application was selected and the window held focus |
| activeSeconds | 0–60 — seconds the user was engaged |
Focus gates open time, not visibility. Two windows side by side are both visible but only one is focused, so a wall-clock minute can never be billed twice.
Engagement is inferred from interaction. Each interaction (click, keydown, mousemove, scroll, touchstart) implies engagement for the following 30 seconds. Overlapping windows are merged rather than summed, and the result is intersected with open time — so reading and thinking count, while a single click before tabbing away cannot bank a full 30 seconds.
activeSeconds ≤ openSeconds holds by construction. A minute that is open with activeSeconds: 0 is a real signal — present but idle — not a gap.
2. Installation
npm install @e-llm-studio/appmod-usage-tracker3. Quick start
The shell instantiates it once. Individual applications integrate nothing.
The shell and the applications it hosts share one window, so DOM events reach the tracker either way and the shell attributes each minute to whichever application is selected. This is what measures every application from day one, including ones that never integrate anything.
import { usageTracker } from '@e-llm-studio/appmod-usage-tracker';
await usageTracker.init({
userId: () => getCurrentUserId(),
sessionId: () => getCurrentSessionId(),
getAccessToken: () => getCurrentAccessToken(),
env: 'DEV',
defaultApplicationId: 'eCG Analytics',
});
// Whenever the on-screen application changes:
usageTracker.setApplicationId('Project Mode');
// When the user leaves an application and none is selected:
usageTracker.setApplicationId(null);Nothing else is required. Focus, interaction, minute aggregation, local buffering, batching, retries and delivery are all handled internally.
Do not instantiate a second tracker inside a hosted application. Two instances would watch the same focus and interaction and write to the same IndexedDB, counting that application's time twice.
4. Configuration
interface UsageTrackerConfig {
// Required
userId: string | (() => string);
sessionId: string | (() => string);
env: 'DEV' | 'QA' | 'PROD';
// Optional
getAccessToken?: () => string | null | undefined;
baseUrl?: string; // Override the built-in service origin (local dev only)
defaultApplicationId?: string; // Default: 'unknown'
flushIntervalMs?: number; // Default: 300_000 (5 minutes)
idleTimeoutMs?: number; // Default: 30_000 (engagement window)
syncBatchSize?: number; // Default: 50 records per request
syncBatchDelayMs?: number; // Default: 2_000 between batches
onRecord?: (record: MinuteRecord) => void;
debug?: boolean;
}userId and sessionId accept getters. Pass a function when the host owns a value that can change while the app runs — it is resolved for each record, so records follow the host rather than pinning whatever existed at init.
getAccessToken supplies the current bearer token for each delivery request. The tracker sends it as Authorization: Bearer <token>. When a getter is configured but returns no token, records remain queued. Page-exit delivery uses fetch with keepalive so it can send that header. Without a getter, existing unauthenticated delivery behavior remains available.
env selects where records are posted. The service origin per environment and the route both live in this package, so hosts configure nothing else:
| env | Endpoint |
| :---- | :---- |
| DEV | https://devllmstudio.creativeworkspace.ai/ecg-service/db/track-application-async |
| QA | https://qallmstudio.creativeworkspace.ai/ecg-service/db/track-application-async |
| PROD | https://llmstudio.creativeworkspace.ai/ecg-service/db/track-application-async |
baseUrl overrides the origin for local development against a mock or tunnel. It is not part of a normal integration — setting it in a deployed environment defeats the guarantee that every application reports to the same service.
5. API
| Method | Purpose |
| :---- | :---- |
| init(config) | Starts tracking. Called once, by the shell. A second call is ignored. |
| setApplicationId(id \| null) | Reports the on-screen application. null means none is selected, and nothing accrues while it holds. Re-asserting the same value is a no-op, so it is safe to call from a render or store subscription. |
| trackTask(taskId, status, metadata?) | Reports a task lifecycle event — status is 'start' or 'end' — against the application selected right now. |
| trackAiTime(requestId, requestStatus, options?) | Reports where an AI request is — 'start', 'ping' or 'end'. Drives the ai_time duration and the ai_queries count, not an event. |
| track(eventName, payload?) | Records an arbitrary point-in-time event. The general form of trackTask. |
| flush() | Writes the in-flight minute and delivers everything pending. Called automatically; rarely needed directly. |
| destroy() | Stops watchers and timers, and detaches listeners. |
Also exported: SERVICE_BASE_URL, resolveBaseUrl, buildEndpoint, TRACKING_PATHS, the default constants, and the individual building blocks (MinuteAggregator, StorageManager, SyncService, watchers, IntervalMath) for testing or custom composition.
6. What gets sent
A flush POSTs a batch:
{
"records": [
{
"id": "123e4567-e89b-12d3-a456-426614174001",
"user_id": "6846ce419d5280f5afd075c9",
"session_id": "550e8400-e29b-41d4-a716-446655440001",
"application": "eCG Analytics",
"environment": "dev",
"open_time": 45,
"active_time": 38,
"ai_time": 22,
"ai_queries": 1,
"record_timestamp": "2026-08-20T04:00:00Z",
"custom_events": [
{
"event_name": "task_start",
"timestamp": "2026-08-20T04:00:12.418Z",
"payload": { "taskId": "ingest-a" }
},
{
"event_name": "task_end",
"timestamp": "2026-08-20T04:00:41.902Z",
"payload": { "taskId": "ingest-a", "rows": 12 }
}
]
}
]
}custom_events is omitted entirely when a record carries none, so a time-only record is byte-identical to what earlier versions sent. payload is free-form and belongs in a JSONB column rather than typed ones — that is what lets a new kind of tracking ship without a migration.
Four things the receiving service must handle:
- Deduplicate on
id. Delivery is at-least-once by design — an unload-time send cannot be confirmed, so the next session re-sends anything outstanding. - Sum records per minute;
record_timestampis not unique. A minute interrupted by the user leaving produces more than one record for that minute, and those are additive rather than duplicates. open_timecan be0. A record exists to carry its events even when the application held no open time that minute — a task ending while the tab is blurred, or while no application is selected. A query filteringopen_time > 0will hide those events.ai_timeis always present and is not bounded byopen_time. An AI request keeps running while the user is in another window, so a record can reportopen_time: 0, ai_time: 60. Any ratio computed against open time can exceed 1.ai_queriescounts what STARTED in that minute, not what ran. The count lands on the minute a request began; its time spreads across every minute it ran.ai_time: 60, ai_queries: 0is a long request continuing, not a gap.- Count
event_nameover a window; starts and ends do not balance within one record. A task spanning a minute boundary putstask_starton one record andtask_endon the next. Total tasks is the count oftask_start, completed tasks the count oftask_end, over whatever window is being reported.
7. Behaviour worth knowing
Records survive tab close, reload, crash and offline periods. Everything is written to IndexedDB before any network call, and anything a previous session failed to deliver is sent as soon as the app next opens.
Tab switching costs no requests. Hiding is not leaving: the in-flight minute is written locally, and delivery happens on the timer or on actual departure.
Backlogs drain in paced batches — 50 records per request, spaced 2 seconds apart — so a long offline period does not arrive as a burst.
The local buffer is capped at 500 records, oldest dropped first. This is the backstop for a service that has been unreachable for hours.
No per-second timer. Everything is computed from buffered timestamps at the minute boundary, because background tabs throttle timers to roughly one per minute.
Machine sleep produces no records for those minutes rather than back-filling them.
trackTaskandtracknever throw. Called beforeinitresolves, they warn once and drop the event. Modes come up independently of whichever host calledinit, and an ordering they do not control must not become an exception in their code path.Events are attributed at call time, not at the minute boundary. A task started in one application and finished in another credits the one that was selected when each event was recorded.
Every request that opens counts as a query — retries and background agent calls included. The field counts queries, not user messages, so a dashboard labelled "messages sent" will read high whenever the backend is unstable. Pass
{ countsAsQuery: false }at a call site that should not count.A re-fired
'start'counts nothing. It opens no interval, so it adds neither time nor a query.AI time merges rather than sums. Two requests overlapping for ten seconds contribute ten seconds, not twenty, so
ai_timestays bounded at 60 and comparable to the fields beside it.An AI request must be pinged from received data, not from a timer. A
setIntervalheartbeat keeps firing after the stream behind it has died, which defeats the guard that would otherwise have caught it. Ping from the point where a chunk arrives.An unended AI request is closed automatically — five minutes after its last ping, or thirty minutes after it started, whichever comes first. Both are configurable, and either close emits an
ai_request_abandonedevent.A mode switch closes every open AI request. The shell's request ends and what is on the right is replaced, so nothing survives to attribute. A caller's own
'end'arriving afterwards is ignored.Events are capped at 200 per application per minute. Past that they are dropped and a single
events_truncatedevent carries the count, so a misbehaving caller shows up in the data rather than silently inflating memory.
8. Upgrading
increment() has been removed from IUsageTracker, and counters from MinuteRecord. Neither had a consumer, and the service contract has no field for either — a counter cannot be summed across the minute boundary a task spans, whereas counting event_name over a window can.
| 1.x | 2.0.0 |
| :---- | :---- |
| tracker.increment('tasks') | tracker.trackTask(taskId, 'start') |
| record.counters | (gone — count custom_events[].event_name) |
| track() threw before init | warns once and drops the event |
| custom_events not delivered | delivered on the minute record |
Upgrading from 2.1.x
Additive only. trackAiTime gains an optional third argument and the record
gains ai_queries; nothing was removed or changed. As with ai_time, the
field appears on every record, so a consumer validating the payload shape
strictly needs to accept it before this version ships.
Upgrading from 2.0.x
Additive only. trackAiTime and the ai_time field are new; nothing was removed
or changed. The one thing to check is downstream: ai_time appears on every
record, so a consumer that validates the payload shape strictly needs to accept
it before this version ships.
Two consequences reach further than the API:
open_time: 0records now exist. Any query filteringopen_time > 0will hide task events.- Starts and ends do not balance within a record. Sum over a window.
9. Development
npm install
npm test # vitest, 148 unit tests
npm run typecheck
npm run lint
npm run build # tsup → dist/ (ESM + CJS + types)prepack builds and prepublishOnly runs the tests, so npm publish cannot ship an untested or stale bundle.
For publishing and versioning, see CONTRIBUTING.md at the repository root.
10. Further documentation
| Document | Covers | | :---- | :---- | | docs/01-overview.md | Feature overview, system invariants, full export surface | | docs/02-architecture.md | Components, data flow, storage schema, wire mapping, lifecycle | | docs/03-usage.md | Full API reference and integration patterns | | docs/04-edge-cases.md | Limitations, edge cases, troubleshooting |
