npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-tracker Part of: the e-LLM Studio library collection Environment: browser only — requires window, document and IndexedDB


Table of contents

  1. What it measures
  2. Installation
  3. Quick start
  4. Configuration
  5. API
  6. What gets sent
  7. Behaviour worth knowing
  8. Upgrading
  9. Development
  10. 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-tracker

3. 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_timestamp is 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_time can be 0. 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 filtering open_time > 0 will hide those events.
  • ai_time is always present and is not bounded by open_time. An AI request keeps running while the user is in another window, so a record can report open_time: 0, ai_time: 60. Any ratio computed against open time can exceed 1.
  • ai_queries counts 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: 0 is a long request continuing, not a gap.
  • Count event_name over a window; starts and ends do not balance within one record. A task spanning a minute boundary puts task_start on one record and task_end on the next. Total tasks is the count of task_start, completed tasks the count of task_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.

  • trackTask and track never throw. Called before init resolves, they warn once and drop the event. Modes come up independently of whichever host called init, 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_time stays bounded at 60 and comparable to the fields beside it.

  • An AI request must be pinged from received data, not from a timer. A setInterval heartbeat 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_abandoned event.

  • 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_truncated event 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: 0 records now exist. Any query filtering open_time > 0 will 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 |