trackrev 0.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.
Files changed (3) hide show
  1. package/README.md +120 -0
  2. package/package.json +25 -0
  3. package/src/index.js +321 -0
package/README.md ADDED
@@ -0,0 +1,120 @@
1
+ # trackrev
2
+
3
+ Your [TrackRev](https://trackrev.io) link analytics, in the terminal — performance per channel,
4
+ per link, the raw click stream, and any single visitor's full journey.
5
+
6
+ Zero dependencies. Node 20 or newer.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ npx trackrev channels
12
+ ```
13
+
14
+ Or keep it around:
15
+
16
+ ```bash
17
+ npm install -g trackrev
18
+ ```
19
+
20
+ ## Authenticate
21
+
22
+ Create a **secret** key under Settings → Developers, then:
23
+
24
+ ```bash
25
+ export TRACKREV_KEY=lk_live_...
26
+ ```
27
+
28
+ Set `TRACKREV_API_URL` to point at a different API base (self-hosted, staging).
29
+
30
+ ## Commands
31
+
32
+ ### `channels` — performance per traffic source
33
+
34
+ ```bash
35
+ trackrev channels # last 30 days
36
+ trackrev channels --days 7
37
+ trackrev channels --ltv # add all-time lifetime value per channel
38
+ ```
39
+
40
+ ```
41
+ channel clicks visitors conversions revenue
42
+ youtube 1,204 980 12.50 2,410.50
43
+ newsletter 310 287 9.00 1,890.00
44
+ ```
45
+
46
+ `conversions` is a decimal because attribution splits credit — one sale touched by two channels
47
+ counts 0.5 on each.
48
+
49
+ ### `links` — performance per short link
50
+
51
+ ```bash
52
+ trackrev links --limit 20
53
+ trackrev links --from 2026-01-01 --to 2026-02-01
54
+ ```
55
+
56
+ ### `clicks` — the raw click stream
57
+
58
+ Newest first, bots excluded, cursor-paginated.
59
+
60
+ ```bash
61
+ trackrev clicks # most recent 100
62
+ trackrev clicks --limit 500 # one page, max size
63
+ trackrev clicks --all # follow the cursor to the end
64
+ trackrev clicks --link <link-id> # one link only
65
+ trackrev clicks --bots # include bot traffic
66
+ ```
67
+
68
+ `--all` stops after 200 pages and says so on stderr, so a runaway cursor can't loop forever.
69
+
70
+ ### `journey` — one visitor's timeline
71
+
72
+ ```bash
73
+ trackrev journey 3f1b8c22-9d4e-4a71-b8c0-2e6f5a91d7e4
74
+ ```
75
+
76
+ Every click, identify event and order in order. The visitor id is the `visitor_id` column from
77
+ `trackrev clicks`.
78
+
79
+ ## Window
80
+
81
+ `channels` and `links` accept:
82
+
83
+ | flag | meaning |
84
+ | --- | --- |
85
+ | `--days N` | last N days (default 30, max 365) |
86
+ | `--from ISO` | explicit start, e.g. `2026-01-01` |
87
+ | `--to ISO` | explicit end (defaults to now) |
88
+
89
+ `--from`/`--to` win over `--days`.
90
+
91
+ ## Output
92
+
93
+ The same data comes out three ways, so the command works both as something you read and as
94
+ something you pipe:
95
+
96
+ ```bash
97
+ trackrev channels # aligned table (a terminal)
98
+ trackrev channels > channels.tsv # tab-separated, raw values (a pipe or file)
99
+ trackrev channels --json | jq . # the API's own JSON body
100
+ ```
101
+
102
+ Piped output is deliberately unformatted — `2410.5`, not `2,410.50` — so `cut` and `awk` see real
103
+ numbers:
104
+
105
+ ```bash
106
+ trackrev channels | cut -f1,5
107
+ trackrev links --limit 10 | column -t
108
+ trackrev clicks --all --json | jq '.clicks[] | select(.country == "BD")'
109
+ ```
110
+
111
+ Warnings, errors and the `journey` caption always go to **stderr**, never into your pipe.
112
+
113
+ ## Exit codes
114
+
115
+ | code | meaning |
116
+ | --- | --- |
117
+ | `0` | success |
118
+ | `1` | no API key, auth failure, plan gate, network error, API error |
119
+ | `2` | usage error — unknown flag or command, bad `--limit`, missing visitor id |
120
+
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "trackrev",
3
+ "version": "0.1.0",
4
+ "description": "Your TrackRev link analytics, in the terminal — channels, links and the raw click stream.",
5
+ "type": "module",
6
+ "bin": {
7
+ "trackrev": "src/index.js"
8
+ },
9
+ "files": [
10
+ "src",
11
+ "README.md"
12
+ ],
13
+ "keywords": [
14
+ "trackrev",
15
+ "analytics",
16
+ "attribution",
17
+ "link-tracking",
18
+ "cli"
19
+ ],
20
+ "homepage": "https://trackrev.io",
21
+ "license": "MIT",
22
+ "engines": {
23
+ "node": ">=20"
24
+ }
25
+ }
package/src/index.js ADDED
@@ -0,0 +1,321 @@
1
+ #!/usr/bin/env node
2
+ // trackrev — your link analytics, in the terminal.
3
+ //
4
+ // trackrev channels --days 7
5
+ // trackrev clicks --all --json > clicks.json
6
+ import { parseArgs } from "node:util";
7
+ import { createRequire } from "node:module";
8
+
9
+ const { version } = createRequire(import.meta.url)("../package.json");
10
+ const BASE = process.env.TRACKREV_API_URL ?? "https://app.trackrev.io/api/v1";
11
+ const KEY = process.env.TRACKREV_KEY;
12
+
13
+ const EXIT_FAIL = 1;
14
+ const EXIT_USAGE = 2;
15
+ const MAX_PAGES = 200;
16
+
17
+ const HELP = `trackrev — your link analytics, in the terminal
18
+
19
+ Usage
20
+ trackrev channels [--ltv] performance per channel
21
+ trackrev links [--limit N] performance per link
22
+ trackrev clicks [--all] [--link ID] the raw click stream
23
+ trackrev journey <visitor-id> one visitor's full timeline
24
+
25
+ Window (channels, links)
26
+ --days N last N days (default 30)
27
+ --from ISO explicit start, e.g. 2026-01-01
28
+ --to ISO explicit end (defaults to now)
29
+
30
+ Options
31
+ --ltv channels: add all-time lifetime value per channel
32
+ --limit N links: rows to return; clicks: page size (max 500)
33
+ --all clicks: follow the cursor to the end of the stream
34
+ --link ID clicks: restrict to one link
35
+ --bots clicks: include bot traffic (excluded by default)
36
+ --json print raw JSON instead of a table
37
+ --version print the version
38
+ --help show this message
39
+
40
+ Output
41
+ A table when you are watching, tab-separated when piped or redirected,
42
+ JSON with --json. Warnings and errors always go to stderr.
43
+
44
+ Environment
45
+ TRACKREV_KEY secret API key, from Settings → Developers
46
+ TRACKREV_API_URL override the API base URL
47
+ `;
48
+
49
+ /* ── plumbing ───────────────────────────────────────────────────────────── */
50
+
51
+ /** Anything that is not data goes to stderr, so it never lands in a pipe. */
52
+ function warn(message) {
53
+ console.error(message);
54
+ }
55
+
56
+ /** Print to stderr and quit with a failure code. */
57
+ function die(message, code = EXIT_FAIL) {
58
+ console.error(message);
59
+ process.exit(code);
60
+ }
61
+
62
+ /** GET a path under the API base, returning the parsed JSON body. */
63
+ async function api(path) {
64
+ if (!KEY) die("No API key. Set TRACKREV_KEY to a secret key from Settings → Developers.");
65
+
66
+ let res;
67
+ try {
68
+ res = await fetch(BASE + path, { headers: { Authorization: `Bearer ${KEY}` } });
69
+ } catch {
70
+ return die(`Could not reach ${BASE} — is the server running?`);
71
+ }
72
+
73
+ if (!res.ok) {
74
+ const body = await res.json().catch(() => null);
75
+ const message = body?.error?.message ?? res.statusText;
76
+ if (res.status === 401) die(`${message}. Check TRACKREV_KEY.`);
77
+ if (res.status === 402) die(`${message}\nUpgrade at https://www.trackrev.io/pricing`);
78
+ if (res.status === 404) die(message);
79
+ die(`API error ${res.status}: ${message}`);
80
+ }
81
+ return res.json();
82
+ }
83
+
84
+ /* ── arguments ──────────────────────────────────────────────────────────── */
85
+
86
+ // parseArgs throws on an unknown flag. Left uncaught that is a stack trace in
87
+ // the user's face; caught, it is a one-line usage error.
88
+ let parsed;
89
+ try {
90
+ parsed = parseArgs({
91
+ allowPositionals: true,
92
+ options: {
93
+ days: { type: "string", default: "30" },
94
+ from: { type: "string" },
95
+ to: { type: "string" },
96
+ limit: { type: "string" },
97
+ link: { type: "string" },
98
+ ltv: { type: "boolean", default: false },
99
+ all: { type: "boolean", default: false },
100
+ bots: { type: "boolean", default: false },
101
+ json: { type: "boolean", default: false },
102
+ version: { type: "boolean", default: false },
103
+ help: { type: "boolean", default: false },
104
+ },
105
+ });
106
+ } catch (err) {
107
+ console.error(`${err.message}\n\nRun: trackrev --help`);
108
+ process.exit(EXIT_USAGE);
109
+ }
110
+ const { positionals, values } = parsed;
111
+ const command = positionals[0];
112
+
113
+ if (values.version) {
114
+ console.log(version);
115
+ process.exit(0);
116
+ }
117
+ if (values.help || !command) {
118
+ console.log(HELP);
119
+ process.exit(0);
120
+ }
121
+
122
+ /**
123
+ * The reporting window as query params. `--from`/`--to` win over `--days`:
124
+ * the server ignores `days` once either bound is present, so sending all three
125
+ * would only be noise in the request log.
126
+ */
127
+ function windowParams() {
128
+ const params = new URLSearchParams();
129
+ if (values.from) params.set("from", values.from);
130
+ if (values.to) params.set("to", values.to);
131
+ if (!values.from && !values.to) params.set("days", values.days);
132
+ return params;
133
+ }
134
+
135
+ /** Parse a numeric flag, failing with a usage error rather than sending NaN. */
136
+ function intFlag(name, rawValue, { min = 1, max }) {
137
+ const n = Number(rawValue);
138
+ if (!Number.isInteger(n) || n < min || n > max) {
139
+ die(`--${name} must be a whole number between ${min} and ${max}.`, EXIT_USAGE);
140
+ }
141
+ return n;
142
+ }
143
+
144
+ /* ── output ─────────────────────────────────────────────────────────────── */
145
+
146
+ const TTY = process.stdout.isTTY;
147
+
148
+ /** Raw value for a pipe: no padding, no separators, empty cell for null. */
149
+ function rawCell(value) {
150
+ return value === null || value === undefined ? "" : String(value);
151
+ }
152
+
153
+ /**
154
+ * Human value for a table. `fixed` is the column's decimal places, so a money
155
+ * column stays uniform (9 → 9.00) instead of following each individual value.
156
+ */
157
+ function pretty(value, fixed = 0) {
158
+ if (value === null || value === undefined || value === "") return "—";
159
+ if (typeof value === "number") {
160
+ if (!Number.isFinite(value)) return "—";
161
+ return value.toLocaleString("en-US", {
162
+ minimumFractionDigits: fixed,
163
+ maximumFractionDigits: fixed,
164
+ });
165
+ }
166
+ return String(value);
167
+ }
168
+
169
+ /**
170
+ * Render rows three ways:
171
+ * --json the API's own body, untouched, for jq
172
+ * a terminal an aligned table
173
+ * a pipe tab-separated raw values, ready for cut/awk
174
+ */
175
+ function emit(columns, rows, body) {
176
+ if (values.json) {
177
+ console.log(JSON.stringify(body, null, 2));
178
+ return;
179
+ }
180
+ if (rows.length === 0) {
181
+ warn("No rows for this window.");
182
+ return;
183
+ }
184
+
185
+ const header = columns.map((c) => c.header);
186
+ const cells = rows.map((row) => columns.map((c) => c.value(row)));
187
+
188
+ if (!TTY) {
189
+ console.log(header.join("\t"));
190
+ for (const line of cells) console.log(line.map(rawCell).join("\t"));
191
+ return;
192
+ }
193
+
194
+ const table = [header, ...cells.map((line) => line.map((v, i) => pretty(v, columns[i].fixed)))];
195
+ const widths = header.map((_, i) => Math.max(...table.map((line) => line[i].length)));
196
+ for (const line of table) {
197
+ console.log(
198
+ line
199
+ .map((v, i) => (columns[i].align === "right" ? v.padStart(widths[i]) : v.padEnd(widths[i])))
200
+ .join(" ")
201
+ .trimEnd(),
202
+ );
203
+ }
204
+ }
205
+
206
+ /* ── commands ───────────────────────────────────────────────────────────── */
207
+
208
+ async function channels() {
209
+ const params = windowParams();
210
+ if (values.ltv) params.set("ltv", "1");
211
+ const body = await api(`/channels?${params}`);
212
+
213
+ const columns = [
214
+ { header: "channel", value: (r) => r.channel },
215
+ { header: "clicks", value: (r) => Number(r.clicks), align: "right" },
216
+ { header: "visitors", value: (r) => Number(r.visitors), align: "right" },
217
+ { header: "conversions", value: (r) => Number(r.conversions), align: "right", fixed: 2 },
218
+ { header: "revenue", value: (r) => Number(r.revenue), align: "right", fixed: 2 },
219
+ ];
220
+ if (values.ltv) {
221
+ columns.push({ header: "ltv", value: (r) => Number(r.ltv), align: "right", fixed: 2 });
222
+ }
223
+
224
+ emit(columns, body.channels, body);
225
+ }
226
+
227
+ async function links() {
228
+ const params = windowParams();
229
+ if (values.limit !== undefined) {
230
+ params.set("limit", String(intFlag("limit", values.limit, { max: 500 })));
231
+ }
232
+ const body = await api(`/links?${params}`);
233
+
234
+ emit(
235
+ [
236
+ { header: "slug", value: (r) => r.slug },
237
+ { header: "channel", value: (r) => r.channel },
238
+ { header: "clicks", value: (r) => Number(r.clicks), align: "right" },
239
+ { header: "visitors", value: (r) => Number(r.visitors), align: "right" },
240
+ { header: "conversions", value: (r) => Number(r.conversions), align: "right", fixed: 2 },
241
+ { header: "revenue", value: (r) => Number(r.revenue), align: "right", fixed: 2 },
242
+ { header: "destination", value: (r) => r.destination },
243
+ ],
244
+ body.links,
245
+ body,
246
+ );
247
+ }
248
+
249
+ async function clicks() {
250
+ const pageSize = values.limit === undefined ? 100 : intFlag("limit", values.limit, { max: 500 });
251
+ const stream = [];
252
+ let before = null;
253
+ let pages = 0;
254
+
255
+ do {
256
+ const params = new URLSearchParams({ limit: String(pageSize) });
257
+ if (before) params.set("before", before);
258
+ if (values.link) params.set("link_id", values.link);
259
+ if (values.bots) params.set("bots", "include");
260
+
261
+ const page = await api(`/clicks?${params}`);
262
+ stream.push(...page.clicks);
263
+ before = page.next_cursor;
264
+
265
+ if (++pages >= MAX_PAGES && before) {
266
+ warn(`Stopped after ${MAX_PAGES} pages (${stream.length} clicks). Narrow it with --link.`);
267
+ break;
268
+ }
269
+ } while (values.all && before);
270
+
271
+ const columns = [
272
+ { header: "ts", value: (r) => r.ts },
273
+ { header: "channel", value: (r) => r.channel },
274
+ { header: "country", value: (r) => r.country },
275
+ { header: "device", value: (r) => r.device },
276
+ { header: "browser", value: (r) => r.browser },
277
+ { header: "visitor", value: (r) => r.visitor_id },
278
+ ];
279
+ if (values.bots) columns.push({ header: "bot", value: (r) => (r.is_bot ? "yes" : "") });
280
+
281
+ emit(columns, stream, { clicks: stream });
282
+ }
283
+
284
+ async function journey() {
285
+ const id = positionals[1];
286
+ if (!id) die("Usage: trackrev journey <visitor-id>", EXIT_USAGE);
287
+
288
+ const body = await api(`/visitors/${encodeURIComponent(id)}/journey`);
289
+ const who = body.visitor;
290
+ // Identity goes to stderr: it is a caption, not a row, so a pipe stays clean.
291
+ if (!values.json) warn(`visitor ${who.id}${who.email ? ` · ${who.email}` : ""}`);
292
+
293
+ emit(
294
+ [
295
+ { header: "ts", value: (r) => r.ts },
296
+ { header: "kind", value: (r) => r.kind },
297
+ { header: "label", value: (r) => r.label },
298
+ { header: "channel", value: (r) => r.channel },
299
+ { header: "link", value: (r) => r.link_slug },
300
+ { header: "amount", value: (r) => (r.amount == null ? null : Number(r.amount)), align: "right", fixed: 2 },
301
+ { header: "detail", value: (r) => r.detail },
302
+ ],
303
+ body.journey,
304
+ body,
305
+ );
306
+ }
307
+
308
+ /* ── dispatch ───────────────────────────────────────────────────────────── */
309
+
310
+ const COMMANDS = { channels, links, clicks, journey };
311
+ const run = COMMANDS[command];
312
+ if (!run) {
313
+ console.error(`Unknown command "${command}".\n\nRun: trackrev --help`);
314
+ process.exit(EXIT_USAGE);
315
+ }
316
+
317
+ try {
318
+ await run();
319
+ } catch (err) {
320
+ die(err?.message ?? String(err));
321
+ }