CLI / Standalone Node Tracker
This guide covers integrating the Pulse tracker into long-running Node processes (CLIs, daemons, scripts, batch jobs) — environments without an HTTP request lifecycle. For request-scoped middleware (Express / NestJS), see the Server SDK Integration Guide.
Overview
The @pulse/server package exposes a Pulse tracker that:
- Persists a per-machine device ID across invocations (matching the format produced by the browser SDK).
- Generates an in-memory session ID per process invocation.
- POSTs events synchronously per call (no queue, no batching across calls) so short-lived CLI processes get reliable delivery.
- Sends the wire body in the same shape as the browser tracker so the receiver routes CLI and web traffic through a single pipeline.
The exposed API matches the browser tracker surface (event, events, attribute, attributes, set, state, stateData, cmd, flush, queueEmpty), so call sites move between platforms with no signature changes.
Installation
npm install @pulse/server @pulse/core
@pulse/core exposes the global pulse proxy that resolves the current tracker via Node's AsyncLocalStorage — meaning any module deep in your codebase can import { pulse } from '@pulse/core' and call pulse.event(...) without having to thread the client through function arguments.
Configuration
import { createClient } from "@pulse/server";
const client = createClient({
app: "your-app-name",
tracker: {
// Required: your application version (will land on the wire as `header.version`).
appVersion: "1.2.3",
// Optional. Defaults to "https://t.picsart.com/events/v2/web".
// Final POST URL is `${serverBaseURL}/${app}`.
// serverBaseURL: "https://t.picsart.com/events/v2/web"
},
});
All tracker.* fields are optional except appVersion — supply the version of your application so events are attributable to the running build.
Initialization
Wrap your process entry point in client.createAsyncContext(initialState, callback). The callback is the rest of your program; inside it (and any async branch from inside it) the pulse proxy from @pulse/core resolves automatically.
// src/index.ts (your CLI entry point)
import { createClient } from "@pulse/server";
import { pulse } from "@pulse/core";
const client = createClient({
app: "gen-ai",
tracker: { appVersion: "1.2.3" },
});
await client.createAsyncContext(
{ user_id: "u-42" }, // initial state — propagates on every event
async () => {
pulse.event({
event: "cli_started", // Put the event name
data: { interactive: process.stdin.isTTY }, // Put your own data
});
await runMyCLI(); // your existing logic; any code inside can import pulse and fire events
await pulse.flush(); // drain in-flight requests before exit
}
);
The two important rules:
- Everything that fires events must run inside
createAsyncContext's callback. Calls fired before the wrap will queue indefinitely (the proxy waits for a context to resolve to). await pulse.flush()before the process exits. Without it, in-flightfetchcalls may be cut off when Node tears down sockets.
Firing events from anywhere
Once createAsyncContext is set up, any module — no matter how deeply imported — can call:
import { pulse } from "@pulse/core";
pulse.event({ event: "image_generated", data: { model: "fill", ms: 4321 } });
pulse.events([
{ event: "step_a", data: { ok: true } },
{ event: "step_b", data: { ok: true } },
]);
pulse.set({ tier_id: "pro" }); // attaches to every subsequent event
pulse.attribute({ type: "$inc", name: "generations_total", value: 1 });
Each top-level call to event() / attribute() POSTs one HTTP request immediately. A single call to events([a, b, c]) POSTs one request with three events in one chunk.
API
The tracker exposes the same surface as the browser tracker. Every method is fire-and-forget at the call site; flush() is the only awaitable for ordering.
| Method | Effect |
|---|---|
pulse.event({event, data}) | Fires one event, immediate POST. |
pulse.events([{event, data}, ...]) | Fires multiple events in one chunk / one POST. |
pulse.attribute({type, name, value}) | Fires one attribute ($overwrite, $inc, $dec). |
pulse.attributes([...]) | Fires multiple attributes in one POST. |
pulse.set({user_id, tier_id, ...}) | Persists state on every subsequent event's header. Keys land snake-case on the wire as given. |
pulse.state({...}) | Alias for set(). |
pulse.stateData({...}) | Merges into the header.data sub-object. |
pulse.cmd('event', payload) | Dispatcher form: cmd('event', e) ≡ event(e); cmd('state', s) ≡ set(s). |
pulse.flush() | Returns a Promise<void> that resolves when all in-flight POSTs complete. Call before process.exit(). |
pulse.queueEmpty() | Returns an rxjs Subject<boolean> that emits true whenever the in-flight set drains. |
Wire format
Each event call produces a single POST to ${tracker.serverBaseURL}/${app} with the same body shape as the browser tracker. Example:
[
{
"config": {
"deviceId": "persistent",
"sessionId": "memory",
"tabId": "none",
"serverCookies": false
},
"header": {
"device_id": "a.c.mowlsme9.23a92245-c873-43a3-9ce0-f1b9e31398c2",
"session_id": "1778233188840_a.c.mowlsme9.23a92245-c873-43a3-9ce0-f1b9e31398c2",
"app": "test_app",
"market": "cli",
"platform": "apple",
"version": "1.2.3",
"v": "1.99.99",
"debug": false,
"app_language": "en",
"segments": [],
"experiments": [],
"infected": [],
"user_id": "u-42",
"data": {
"language_code": "en",
"cpus": 14,
"touch": false
}
},
"events": [
{
"event": "cli_started",
"data": { "interactive": true },
"event_id": "a.c.mowltk3v.1782daa6-f245-49b8-9e46-4b2d7c146635",
"timestamp": 1778225954827
}
],
"attributes": []
}
]
Notable differences from the browser wire body:
header.marketis always"cli"(browser sends"web").header.platformis one of"apple"(macOS),"windows","linux", or"unknown"— resolved fromprocess.platform.config.sessionIdis"memory"(one session per process invocation; not persisted) andtabIdis"none".config.serverCookiesisfalse.
Identifier formats are identical to the browser SDK:
device_id,event_id:a.c.{base36-timestamp}.{uuid-v4}— e.g.,a.c.mowlsme9.23a92245-….session_id:${Date.now()}_${deviceId}.
Device ID persistence
The tracker generates the device ID on first invocation and persists it under a platform-appropriate directory:
| Platform | Path |
|---|---|
| macOS | ~/Library/Application Support/pulse-cli-sdk/<app>/device-id |
| Linux | ${XDG_CONFIG_HOME:-$HOME/.config}/pulse-cli-sdk/<app>/device-id |
| Windows | %APPDATA%\pulse-cli-sdk\<app>\device-id |
The directory is scoped per app so multiple Pulse-instrumented CLIs on one machine each get distinct device IDs.
All filesystem errors are swallowed — if persistence fails (read-only FS, EACCES, etc.), the SDK falls back to an in-memory device ID for that run and continues without throwing.
Environment variables
| Variable | Effect |
|---|---|
PULSE_OPT_OUT=1 | Skip the createAsyncContext wrap entirely. All tracker calls become no-ops; no HTTP requests are made. |
PULSE_CLI_SDK_HOME=<path> | Override the parent directory for the device-id file. Useful for sandboxed CI runners or isolated dev/test setups. |
PULSE_SERVER_URL=<url> | Override tracker.serverBaseURL for the current process. Useful for pointing at a local capture listener while debugging. |
These conventions are owned by the consumer (your CLI) — @pulse/server doesn't read them directly. The example below shows how a CLI consumer wires them.
Reference implementation: a CLI tracker module
Drop this into your CLI to encapsulate the setup:
// src/services/pulse.service.ts
import { createClient } from "@pulse/server";
import type { PulseClient } from "@pulse/server";
export function createPulseClient(): PulseClient {
return createClient({
app: "app_name", // provide your app name
tracker: {
appVersion: process.env.MY_CLI_VERSION,
...(process.env.PULSE_SERVER_URL
? { serverBaseURL: process.env.PULSE_SERVER_URL }
: {}),
},
});
}
export function getInitialPulseState(): Record<string, unknown> {
// Populate from any auth/credential store your CLI uses.
const userId = process.env.MY_CLI_USER_ID;
return userId ? { user_id: userId } : {};
}
Then in your entry point:
// src/index.ts
import { pulse } from "@pulse/core";
import {
createPulseClient,
getInitialPulseState,
} from "./services/pulse.service";
async function main(): Promise<void> {
// ... your existing CLI logic ...
}
if (process.env.PULSE_OPT_OUT === "1") {
await main();
} else {
const client = createPulseClient();
// Wrap the entire CLI lifecycle in a Pulse async context. After this,
// any module can `import { pulse } from '@pulse/core'` and the proxy will
// resolve to this client's tracker via AsyncLocalStorage.
await client.createAsyncContext(getInitialPulseState(), async () => {
await main();
await pulse.flush();
});
}
Notes
- No queue. Each top-level call POSTs immediately. Consumers wanting batching should call
pulse.events([...])with the array form. - Telemetry never breaks the host. All transport failures (network errors, non-2xx responses, malformed bodies) are swallowed inside the SDK — your CLI is never interrupted by tracker problems.
process.once('beforeExit', ...)is registered automatically as a soft safety net to drain in-flight requests on natural loop exit. It does not fire onprocess.exit()— explicitawait pulse.flush()remains the only deterministic drain.- Process-level singleton. One
createClient+ onecreateAsyncContextper process. Multiple clients in the same process work but the globalpulseproxy resolves to whichever context is currently active on the async stack.