// ==UserScript==
// @name ChatGPT Insight Tracker
// @namespace https://greasyfork.org/en/users/1019658-aayush-dutt
// @version 1.0
// @description Extract insights from your ChatGPT usage and export them
// @author aayushdutt
// @match https://chatgpt.com/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_addStyle
// @grant GM_registerMenuCommand
// @grant unsafeWindow
// @run-at document-start
// @all-frames true
// @license MIT
// @link
// ==/UserScript==
(function () {
"use strict";
// Prevent multi-frame double injection; only run in top window
if (unsafeWindow.top !== unsafeWindow.self) {
return;
}
class Config {
static STORAGE_KEY = "chatgpt_usage_logs";
static SETTINGS_KEY = "chatgpt_tracker_settings";
static TARGET_URL = "https://chatgpt.com/backend-api/f/conversation";
static DEFAULT_SETTINGS = {
truncatePrompt: true,
truncatePromptLen: 160,
anonymizePrompt: false,
retention: { maxCount: null, maxDays: null },
};
static HEADER_BUTTON_ID = "usage-logs-header-button";
}
class SettingsService {
async get() {
const s = await GM_getValue(Config.SETTINGS_KEY, Config.DEFAULT_SETTINGS);
return {
...Config.DEFAULT_SETTINGS,
...(s || {}),
retention: {
...Config.DEFAULT_SETTINGS.retention,
...(s?.retention || {}),
},
};
}
async set(next) {
await GM_setValue(Config.SETTINGS_KEY, next);
}
}
class LogsService {
constructor(settings) {
this.settings = settings;
}
async getAll() {
const logs = await GM_getValue(Config.STORAGE_KEY, []);
return (Array.isArray(logs) ? logs : []).map((l) => ({
timestamp: l.timestamp || new Date().toISOString(),
model: l.model ?? null,
prompt: l.prompt ?? "",
conversationId: l.conversationId ?? null,
durations: l.durations ?? { msToFirstToken: null, msTotal: null },
}));
}
async save(entry) {
const logs = await this.getAll();
logs.push(entry);
const settings = await this.settings.get();
const rawMaxDays = settings.retention?.maxDays;
const maxDays =
rawMaxDays == null || rawMaxDays === "" ? null : Number(rawMaxDays);
const byAge = (() => {
if (!maxDays || isNaN(maxDays) || maxDays <= 0) return logs;
const cutoff = Date.now() - maxDays * 24 * 60 * 60 * 1000;
return logs.filter((l) => {
const t = Date.parse(l.timestamp);
return isNaN(t) ? true : t >= cutoff;
});
})();
const rawMaxCount = settings.retention?.maxCount;
const maxCount =
rawMaxCount == null || rawMaxCount === "" ? null : Number(rawMaxCount);
const trimmed =
!maxCount || isNaN(maxCount) || maxCount <= 0
? byAge
: byAge.slice(-maxCount);
await GM_setValue(Config.STORAGE_KEY, trimmed);
}
async clearAndRefreshUI() {
await GM_setValue(Config.STORAGE_KEY, []);
const existingModal = document.getElementById("usage-modal-container");
if (existingModal) existingModal.remove();
// UI will re-open via handler
}
}
function csvEscape(val) {
const s = String(val ?? "");
if (s.includes("\n") || s.includes(",") || s.includes('"')) {
return '"' + s.replace(/"/g, '""') + '"';
}
return s;
}
function safeStr(v) {
return v == null ? "" : String(v);
}
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => {
URL.revokeObjectURL(url);
a.remove();
}, 0);
}
class StreamProcessor {
constructor(settings, logs) {
this.settings = settings;
this.logs = logs;
}
async process(response, requestMeta) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let lineBuffer = "";
let userPrompt = null;
let modelSlug = null;
let defaultModelSlug = null;
let conversationId = null;
let serverMeta = null;
let tFirstToken = null;
let tDone = null;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
lineBuffer += chunk;
const lines = lineBuffer.split("\n");
lineBuffer = lines.pop() || "";
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line || !line.startsWith("data:")) continue;
const dataStr = line.slice(5).trim();
if (dataStr === "[DONE]") {
tDone = tDone || Date.now();
continue;
}
try {
const data = JSON.parse(dataStr);
if (data.type === "input_message") {
conversationId = conversationId || data.conversation_id || null;
const parts0 = data.input_message?.content?.parts?.[0];
if (
!userPrompt &&
typeof parts0 === "string" &&
parts0.length
) {
userPrompt = parts0;
}
} else if (data.type === "server_ste_metadata") {
conversationId = conversationId || data.conversation_id || null;
serverMeta = data.metadata || serverMeta;
if (serverMeta) {
modelSlug = modelSlug || serverMeta.model_slug || null;
defaultModelSlug =
defaultModelSlug || serverMeta.default_model_slug || null;
}
} else if (data.type === "message_marker") {
if (
data.marker === "user_visible_token" &&
data.event === "first"
) {
tFirstToken = tFirstToken || Date.now();
}
} else if (data.type === "message_stream_complete") {
tDone = tDone || Date.now();
}
if (data.v) {
conversationId = conversationId || data.conversation_id || null;
const vMsg = data.v.message;
if (vMsg && !modelSlug && vMsg.metadata?.model_slug) {
modelSlug = vMsg.metadata.model_slug;
}
}
} catch (_) {}
}
}
const tRequest = requestMeta?.requestStartTs || Date.now();
const msToFirstToken = tFirstToken ? tFirstToken - tRequest : null;
const msTotal = (tDone || Date.now()) - tRequest;
const settings = await this.settings.get();
let promptForLog = (userPrompt || "").trim();
const twoLines = promptForLog.split(/\r?\n/).slice(0, 2).join("\n");
promptForLog = twoLines;
if (settings.anonymizePrompt) {
promptForLog = "[anonymized]";
} else if (
settings.truncatePrompt &&
promptForLog.length > settings.truncatePromptLen
) {
promptForLog = `${promptForLog.slice(
0,
settings.truncatePromptLen
)}...`;
}
const resolvedModel = modelSlug || defaultModelSlug || null;
if (!promptForLog || !resolvedModel) {
return;
}
const logEntry = {
timestamp: new Date().toISOString(),
model: resolvedModel,
prompt: promptForLog,
conversationId,
durations: { msToFirstToken, msTotal },
};
await this.logs.save(logEntry);
} catch (error) {
console.error("ChatGPT Tracker: Error processing stream:", error);
try {
reader.cancel();
} catch (_) {}
}
}
}
class FetchInterceptor {
constructor(streamProcessor) {
this.streamProcessor = streamProcessor;
this._installed = false;
this._originalFetch = null;
}
install() {
if (this._installed) return;
this._installed = true;
this._originalFetch = unsafeWindow.fetch;
const self = this;
unsafeWindow.fetch = async function (...args) {
const url = typeof args[0] === "string" ? args[0] : args[0].url;
if (!url || !String(url).startsWith(Config.TARGET_URL)) {
return self._originalFetch.apply(this, args);
}
console.log("ChatGPT Tracker: Intercepted conversation request.");
try {
const requestStartTs = Date.now();
const response = await self._originalFetch.apply(this, args);
const cloned = response.clone();
self.streamProcessor.process(cloned, { requestStartTs });
return response;
} catch (err) {
console.error(
"ChatGPT Tracker: Error intercepting fetch request:",
err
);
return self._originalFetch.apply(this, args);
}
};
}
}
function injectStyles() {
GM_addStyle(`
#usage-modal-container {
position: fixed;
z-index: 99999;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--color-bg: #202123;
--color-surface: #2a2b32;
--color-border: #3a3b44;
--color-chip-border: rgba(255,255,255,0.35);
--color-text: #e6e6e7;
--color-muted: #b2b3b8;
--color-primary: #e6e6e7;
--color-primary-hover: #d4d4d8;
--color-danger: #ef4444;
--color-button-bg: #2d2f33;
--color-button-hover: #3a3d42;
--color-input-bg: #202123;
--color-input-border: #3f4145;
--color-focus-ring: rgba(230, 230, 231, 0.35);
}
#usage-modal-content {
background-color: var(--color-surface);
color: var(--color-text);
margin: auto;
padding: 24px;
border: 1px solid var(--color-border);
border-radius: 12px;
width: 80%;
max-width: 900px;
max-height: 85vh;
display: flex;
flex-direction: column;
box-shadow: 0 5px 15px rgba(0,0,0,0.5);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 16px;
border-bottom: 1px solid var(--color-border);
}
.modal-actions { display: inline-flex; gap: 8px; align-items: center; margin-left: auto; }
.modal-button, .dropdown > .modal-button { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.modal-button svg { flex: 0 0 auto; }
.dropdown > .modal-button svg { margin-left: 0; }
.modal-header h2 {
margin: 0;
font-size: 20px;
}
.modal-button {
border: none;
background-color: var(--color-button-bg);
color: var(--color-text);
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.2s;
border: 1px solid var(--color-border);
}
.modal-button.primary {
background-color: var(--color-primary);
border-color: transparent;
color: #0b1613;
}
.modal-button.primary:hover {
background-color: var(--color-primary-hover);
}
.modal-button.clear {
background-color: transparent;
color: var(--color-danger);
border: 1px solid var(--color-danger);
}
.modal-button.clear:hover {
background-color: transparent;
color: #f87171;
border-color: #f87171;
}
.modal-button.close {
font-size: 24px;
font-weight: bold;
line-height: 1;
padding: 4px 10px;
background: transparent;
color: var(--color-muted);
border: none;
}
.modal-button:hover {
background-color: var(--color-button-hover);
}
.modal-button:focus-visible {
outline: none;
box-shadow: 0 0 0 2px var(--color-focus-ring);
}
.modal-button.active {
background: rgba(255, 255, 255, 0.12);
border-color: #6b7280;
}
.dropdown { position: relative; }
.dropdown-menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
min-width: 140px;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: 8px;
padding: 6px;
display: none;
box-shadow: 0 8px 20px rgba(0,0,0,0.35);
z-index: 100000;
}
.dropdown-menu.open { display: block; }
.dropdown-item {
background: transparent;
color: var(--color-text);
border: none;
border-radius: 6px;
padding: 8px 10px;
width: 100%;
text-align: left;
cursor: pointer;
}
.dropdown-item:hover { background: var(--color-button-hover); }
#usage-logs-header-button {
border: 1px solid rgba(255, 255, 255, 0.15);
}
#usage-logs-header-button:hover,
#usage-logs-header-button:focus-visible {
border-color: rgba(255, 255, 255, 0.4);
}
.table-container { margin-top: 16px; overflow: auto; min-height: 0; flex: 1 1 auto; }
.table-controls { display: flex; gap: 8px; align-items: center; margin-top: 12px; }
.model-filter { min-width: 150px; padding-right: 34px; background-position: right 8px center; }
.summary { margin-top: 12px; }
.tabs { display: flex; gap: 6px; flex-wrap: wrap; }
.tabs .tab { background: var(--color-button-bg); color: var(--color-text); border: 1px solid var(--color-border); border-radius: 999px; padding: 4px 9px; cursor: pointer; transition: background-color 0.15s, border-color 0.15s; }
.tabs .tab:hover { background: rgba(255, 255, 255, 0.08); border-color: #6b7280; }
.tabs .tab.active { background: rgba(255, 255, 255, 0.12); border-color: #6b7280; color: var(--color-text); }
.summary-content { margin-top: 10px; }
.summary-content .cards { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; }
.summary-content .card { background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 10px; padding: 10px 12px; box-shadow: 0 0 0 1px rgba(255,255,255,0.03) inset; }
.summary-content .card .label { color: var(--color-muted); font-size: 12px; margin-bottom: 4px; }
.summary-content .card .value { font-size: 18px; font-weight: 600; }
.sparkline { margin-top: 10px; background: var(--color-bg); border: 1px solid var(--color-border); border-radius: 8px; padding: 6px; }
.sparkline canvas { width: 100%; height: 60px; display: block; }
.view-tabs { margin-top: 12px; }
.model-cards { display: grid; grid-template-columns: repeat( auto-fit, minmax(220px, 1fr) ); gap: 12px; margin-top: 8px; }
.model-card .model-title { margin-bottom: 8px; }
.model-card .model-metrics { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 6px; }
.modal-input, .modal-select { background-color: var(--color-input-bg); color: var(--color-text); border: 1px solid var(--color-input-border); border-radius: 8px; padding: 8px 10px; transition: border-color 0.2s, box-shadow 0.2s, background-color 0.2s; }
.input-w-80 { width: 80px; }
.input-w-100 { width: 100px; }
.modal-input::placeholder { color: var(--color-muted); }
.modal-input:focus, .modal-select:focus { outline: none; border-color: var(--color-primary); box-shadow: 0 0 0 2px var(--color-focus-ring); }
#usage-modal-content table { width: 100%; border-collapse: collapse; }
.logs-view { display: flex; flex-direction: column; min-height: 0; flex: 1 1 auto; }
#usage-modal-content th, #usage-modal-content td { border-bottom: 1px solid var(--color-border); padding: 12px 8px; text-align: left; vertical-align: top; }
#usage-modal-content th { font-weight: 600; position: sticky; top: 0; background-color: var(--color-surface); }
#usage-modal-content td { font-size: 14px; }
.model-slug { background-color: rgba(255, 255, 255, 0.08); color: var(--color-text); padding: 4px 8px; border-radius: 12px; font-family: monospace; font-size: 0.9em; white-space: nowrap; border: 1px solid var(--color-chip-border); box-shadow: 0 0 0 1px rgba(255,255,255,0.06) inset; }
.prompt-cell { white-space: pre-wrap; word-break: break-word; max-width: 450px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.conv-cell { font-family: monospace; font-size: 12px; color: var(--color-muted); max-width: 200px; word-break: break-all; }
.conv-link { color: var(--color-muted); text-decoration: underline dotted; text-underline-offset: 2px; cursor: pointer; }
.conv-link:hover, .conv-link:focus-visible { text-decoration: underline; color: var(--color-text); }
.conv-link:focus-visible { outline: none; }
#usage-modal-content tbody tr:hover { background-color: rgba(255, 255, 255, 0.03); }
#usage-modal-content, #usage-modal-content .table-container, #usage-modal-content .dropdown-menu { scrollbar-width: thin; scrollbar-color: rgba(255, 255, 255, 0.25) transparent; }
#usage-modal-content::-webkit-scrollbar, #usage-modal-content .table-container::-webkit-scrollbar, #usage-modal-content .dropdown-menu::-webkit-scrollbar { width: 10px; height: 10px; }
#usage-modal-content::-webkit-scrollbar-track, #usage-modal-content .table-container::-webkit-scrollbar-track, #usage-modal-content .dropdown-menu::-webkit-scrollbar-track { background: transparent; }
#usage-modal-content::-webkit-scrollbar-thumb, #usage-modal-content .table-container::-webkit-scrollbar-thumb, #usage-modal-content .dropdown-menu::-webkit-scrollbar-thumb { background: rgba(255, 255, 255, 0.25); border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; }
#usage-modal-content::-webkit-scrollbar-thumb:hover, #usage-modal-content .table-container::-webkit-scrollbar-thumb:hover, #usage-modal-content .dropdown-menu::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.35); }
.settings-panel { margin: 8px 0 0 0; padding: 16px; border: 1px solid var(--color-border); border-radius: 10px; background: var(--color-bg); display: flex; flex-direction: column; gap: 12px; }
.settings-row { display: flex; gap: 12px; align-items: center; margin: 0; flex-wrap: wrap; }
.settings-row label { display: flex; gap: 6px; align-items: center; color: var(--color-text); }
.settings-panel input[type="number"] { background: var(--color-input-bg); color: var(--color-text); border: 1px solid var(--color-input-border); border-radius: 8px; padding: 6px 8px; }
.settings-panel input[type="number"]:focus { outline: none; border-color: var(--color-primary); box-shadow: 0 0 0 2px var(--color-focus-ring); }
.settings-panel input[type="checkbox"] { accent-color: var(--color-primary); }
`);
}
class UiManager {
constructor(settings, logs) {
this.settings = settings;
this.logs = logs;
}
async showModal() {
const existingModal = document.getElementById("usage-modal-container");
if (existingModal) existingModal.remove();
const logs = await this.logs.getAll();
const container = document.createElement("div");
container.id = "usage-modal-container";
const modal = document.createElement("div");
modal.id = "usage-modal-content";
const header = document.createElement("div");
header.className = "modal-header";
const title = document.createElement("h2");
title.textContent = "ChatGPT Usage Logs";
const clearButton = document.createElement("button");
clearButton.className = "modal-button clear";
clearButton.innerHTML = `
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M9 4h6a1 1 0 011 1v1h4a1 1 0 110 2h-1.08l-1.3 11.05A2 2 0 0115.63 21H8.37a2 2 0 01-1.99-1.95L5.08 8H4a1 1 0 110-2h4V5a1 1 0 011-1zm2 0v1h2V4h-2zM7.1 8l1.2 10h7.4l1.2-10H7.1z"/>
</svg>
Clear All Logs`;
clearButton.onclick = async () => {
if (
confirm(
"Are you sure you want to delete all usage logs? This cannot be undone."
)
) {
await this.logs.clearAndRefreshUI();
container.remove();
await this.showModal();
}
};
const exportWrap = document.createElement("div");
exportWrap.className = "dropdown";
const exportBtn = document.createElement("button");
exportBtn.className = "modal-button";
exportBtn.innerHTML = `
<span>Export</span>
<svg width="14" height="14" viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg">
<path d="M5.23 7.21a.75.75 0 011.06.02L10 11.126l3.71-3.896a.75.75 0 011.08 1.04l-4.24 4.46a.75.75 0 01-1.08 0L5.21 8.27a.75.75 0 01.02-1.06z"/>
</svg>
`;
const exportMenu = document.createElement("div");
exportMenu.className = "dropdown-menu";
const exportJsonItem = document.createElement("button");
exportJsonItem.className = "dropdown-item";
exportJsonItem.textContent = "JSON";
exportJsonItem.onclick = async () => this.exportLogs("json");
const exportCsvItem = document.createElement("button");
exportCsvItem.className = "dropdown-item";
exportCsvItem.textContent = "CSV";
exportCsvItem.onclick = async () => this.exportLogs("csv");
exportMenu.appendChild(exportJsonItem);
exportMenu.appendChild(exportCsvItem);
exportWrap.appendChild(exportBtn);
exportWrap.appendChild(exportMenu);
let onExportDocClick = null;
exportBtn.onclick = (e) => {
e.stopPropagation();
const isOpen = exportMenu.classList.toggle("open");
if (isOpen) {
onExportDocClick = (evt) => {
if (!exportWrap.contains(evt.target)) {
exportMenu.classList.remove("open");
document.removeEventListener("click", onExportDocClick, true);
onExportDocClick = null;
}
};
setTimeout(
() => document.addEventListener("click", onExportDocClick, true),
0
);
} else if (onExportDocClick) {
document.removeEventListener("click", onExportDocClick, true);
onExportDocClick = null;
}
};
const settingsBtn = document.createElement("button");
settingsBtn.textContent = "Settings";
settingsBtn.className = "modal-button";
settingsBtn.id = "usage-settings-btn";
settingsBtn.onclick = () => this.toggleSettingsPanel();
const closeButton = document.createElement("button");
closeButton.innerHTML = "×";
closeButton.className = "modal-button close";
closeButton.onclick = () => container.remove();
header.appendChild(title);
const actions = document.createElement("div");
actions.className = "modal-actions";
actions.appendChild(clearButton);
actions.appendChild(exportWrap);
actions.appendChild(settingsBtn);
actions.appendChild(closeButton);
header.appendChild(actions);
const controls = document.createElement("div");
controls.className = "table-controls";
const modelSelect = document.createElement("select");
modelSelect.className = "modal-select model-filter";
const models = Array.from(
new Set(logs.map((l) => l.model).filter(Boolean))
);
const allOpt = document.createElement("option");
allOpt.value = "";
allOpt.textContent = "All models";
modelSelect.appendChild(allOpt);
models.forEach((m) => {
const opt = document.createElement("option");
opt.value = m;
opt.textContent = m;
modelSelect.appendChild(opt);
});
const searchInput = document.createElement("input");
searchInput.type = "search";
searchInput.placeholder = "Search prompt...";
searchInput.className = "modal-input";
controls.appendChild(modelSelect);
controls.appendChild(searchInput);
const summary = document.createElement("div");
summary.className = "summary";
summary.innerHTML = `
<div class="tabs summary-tabs" role="tablist">
<button class="tab active" data-range="hour" role="tab">Last hour</button>
<button class="tab" data-range="today" role="tab">Today</button>
<button class="tab" data-range="week" role="tab">This week</button>
<button class="tab" data-range="month" role="tab">This month</button>
<button class="tab" data-range="all" role="tab">All time</button>
</div>
<div class="summary-content">
<div class="cards">
<div class="card"><div class="label">Prompts</div><div class="value" id="sum-prompts">0</div></div>
<div class="card"><div class="label">Avg first token (ms)</div><div class="value" id="sum-first">-</div></div>
<div class="card"><div class="label">Avg total (s)</div><div class="value" id="sum-total">-</div></div>
</div>
<div class="sparkline"><canvas id="sum-sparkline"></canvas></div>
<div class="model-cards" id="sum-model-cards"></div>
</div>
`;
const tabContainer = summary.querySelector(".summary-tabs");
const promptsEl = summary.querySelector("#sum-prompts");
const avgFirstEl = summary.querySelector("#sum-first");
const avgTotalEl = summary.querySelector("#sum-total");
const modelCards = summary.querySelector("#sum-model-cards");
const sparkCanvas = summary.querySelector("#sum-sparkline");
const drawSparkline = (rows) => {
if (!sparkCanvas) return;
const dpr = Math.max(1, Math.floor(unsafeWindow.devicePixelRatio || 1));
const cssHeight = 60;
const cssWidth = sparkCanvas.clientWidth || 600;
const w = Math.max(150, cssWidth);
const h = cssHeight;
if (sparkCanvas.width !== w * dpr) sparkCanvas.width = w * dpr;
if (sparkCanvas.height !== h * dpr) sparkCanvas.height = h * dpr;
const ctx = sparkCanvas.getContext("2d");
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
// Baseline
ctx.strokeStyle = "rgba(255,255,255,0.15)";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(0, h - 12);
ctx.lineTo(w, h - 12);
ctx.stroke();
const times = rows
.map((l) => Date.parse(l.timestamp))
.filter((t) => !isNaN(t))
.sort((a, b) => a - b);
if (times.length === 0) return;
const minT = times[0];
const maxT =
times[times.length - 1] === minT ? minT + 1 : times[times.length - 1];
const bins = Math.min(60, Math.max(10, Math.round(w / 12)));
const counts = new Array(bins).fill(0);
const span = maxT - minT;
for (const t of times) {
const r = (t - minT) / span;
let idx = Math.floor(r * bins);
if (idx >= bins) idx = bins - 1;
if (idx < 0) idx = 0;
counts[idx] += 1;
}
const maxC = counts.reduce((m, v) => (v > m ? v : m), 0) || 1;
const padX = 6;
const padY = 12;
const innerW = w - padX * 2;
const innerH = h - padY * 2;
ctx.strokeStyle = "#e6e6e7";
ctx.lineWidth = 1.5;
ctx.beginPath();
for (let i = 0; i < bins; i++) {
const x =
padX + (bins === 1 ? innerW / 2 : (innerW * i) / (bins - 1));
const y = padY + (1 - counts[i] / maxC) * innerH;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
};
const tableContainer = document.createElement("div");
tableContainer.className = "table-container";
const table = document.createElement("table");
table.innerHTML = `
<thead>
<tr>
<th>Timestamp</th>
<th>Model Used</th>
<th>First Token (ms)</th>
<th>Total (s)</th>
<th>Conversation</th>
<th>Prompt</th>
</tr>
</thead>
`;
const tbody = document.createElement("tbody");
const getFilteredLogs = () => {
const q = (searchInput.value || "").toLowerCase();
const mf = modelSelect.value || "";
return logs
.slice()
.reverse()
.filter((log) => (mf ? log.model === mf : true))
.filter((log) =>
q ? (log.prompt || "").toLowerCase().includes(q) : true
);
};
const startOfToday = () => {
const d = new Date();
d.setHours(0, 0, 0, 0);
return d;
};
const startOfWeekMonday = () => {
const d = new Date();
d.setHours(0, 0, 0, 0);
const day = (d.getDay() + 6) % 7;
d.setDate(d.getDate() - day);
return d;
};
const startOfMonth = () => {
const d = new Date();
d.setHours(0, 0, 0, 0);
d.setDate(1);
return d;
};
const startOfHour = () => {
const d = new Date();
d.setMinutes(0, 0, 0);
return d;
};
const summarizeLogs = (rows) => {
if (!rows.length) {
return { count: 0, avgFirstMs: null, avgTotalSec: null, byModel: {} };
}
let count = 0;
let sumFirst = 0;
let sumTotalSec = 0;
const byModel = {};
for (const log of rows) {
count++;
const first =
typeof log.durations?.msToFirstToken === "number"
? log.durations.msToFirstToken
: null;
const totalSec =
typeof log.durations?.msTotal === "number"
? log.durations.msTotal / 1000
: null;
if (first != null) sumFirst += first;
if (totalSec != null) sumTotalSec += totalSec;
const m = log.model || "(unknown)";
const mStat = byModel[m] || {
count: 0,
sumFirst: 0,
sumTotalSec: 0,
haveFirst: 0,
haveTotal: 0,
};
mStat.count++;
if (first != null) {
mStat.sumFirst += first;
mStat.haveFirst++;
}
if (totalSec != null) {
mStat.sumTotalSec += totalSec;
mStat.haveTotal++;
}
byModel[m] = mStat;
}
const avgFirstMs = count ? Math.round(sumFirst / count) : null;
const avgTotalSec = count
? Number((sumTotalSec / count).toFixed(2))
: null;
return { count, avgFirstMs, avgTotalSec, byModel };
};
const filterByRange = (rows, range) => {
if (range === "all") return rows;
const now = Date.now();
let t0;
if (range === "hour") t0 = +startOfHour();
else if (range === "today") t0 = +startOfToday();
else if (range === "week") t0 = +startOfWeekMonday();
else if (range === "month") t0 = +startOfMonth();
else return rows;
return rows.filter((log) => {
const t = Date.parse(log.timestamp);
return !isNaN(t) && t >= t0 && t <= now;
});
};
let activeRange = "hour";
const renderSummary = () => {
const rows = logs.slice().reverse();
const inRange = filterByRange(rows, activeRange);
const stats = summarizeLogs(inRange);
promptsEl.textContent = String(stats.count);
avgFirstEl.textContent =
stats.avgFirstMs == null ? "-" : String(stats.avgFirstMs);
avgTotalEl.textContent =
stats.avgTotalSec == null ? "-" : String(stats.avgTotalSec);
modelCards.innerHTML = "";
const allModelKeys = Array.from(
new Set(rows.map((l) => l.model || "(unknown)").filter(Boolean))
);
const entries = allModelKeys.map((model) => {
const m = stats.byModel[model] || {
count: 0,
sumFirst: 0,
sumTotalSec: 0,
haveFirst: 0,
haveTotal: 0,
};
return [model, m];
});
drawSparkline(inRange);
if (!entries.length) {
const empty = document.createElement("div");
empty.className = "card";
empty.textContent = "No data";
modelCards.appendChild(empty);
return;
}
for (const [model, m] of entries) {
const avgF = m.haveFirst ? Math.round(m.sumFirst / m.haveFirst) : "-";
const avgT = m.haveTotal
? (m.sumTotalSec / m.haveTotal).toFixed(2)
: "-";
const card = document.createElement("div");
card.className = "card model-card";
card.innerHTML = `
<div class="model-title"><span class="model-slug">${model}</span></div>
<div class="model-metrics">
<div><span class="label">Prompts</span><div class="value">${m.count}</div></div>
<div><span class="label">Avg first (ms)</span><div class="value">${avgF}</div></div>
<div><span class="label">Avg total (s)</span><div class="value">${avgT}</div></div>
</div>
`;
modelCards.appendChild(card);
}
};
const renderRows = () => {
tbody.innerHTML = "";
const rows = getFilteredLogs();
if (rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="6">No matching logs.</td></tr>';
renderSummary();
return;
}
renderSummary();
rows.forEach((log) => {
const tr = document.createElement("tr");
const formattedDate = new Date(log.timestamp).toLocaleString();
const firstMs = log.durations?.msToFirstToken ?? "-";
const totalSec =
typeof log.durations?.msTotal === "number"
? (log.durations.msTotal / 1000).toFixed(2)
: "-";
const conv = log.conversationId || "-";
const convCell =
conv !== "-"
? `<a href="https://chatgpt.com/c/${conv}" target="_blank" rel="noopener" class="conv-link">${conv}</a>`
: "-";
tr.innerHTML = `
<td>${formattedDate}</td>
<td><span class="model-slug">${
log.model ?? "-"
}</span></td>
<td>${firstMs}</td>
<td>${totalSec}</td>
<td class="conv-cell">${convCell}</td>
<td class="prompt-cell">${log.prompt || ""}</td>
`;
tbody.appendChild(tr);
});
};
if (logs.length === 0) {
tbody.innerHTML =
'<tr><td colspan="6">No usage data recorded yet. Start a new chat to begin tracking.</td></tr>';
renderSummary();
} else {
renderRows();
}
table.appendChild(tbody);
tableContainer.appendChild(table);
controls.oninput = renderRows;
modelSelect.onchange = renderRows;
searchInput.oninput = renderRows;
tabContainer.onclick = (e) => {
const btn = e.target.closest(".tab");
if (!btn) return;
tabContainer
.querySelectorAll(".tab")
.forEach((t) => t.classList.remove("active"));
btn.classList.add("active");
activeRange = btn.getAttribute("data-range");
renderRows();
};
const viewTabs = document.createElement("div");
viewTabs.className = "tabs view-tabs";
viewTabs.innerHTML = `
<button class="tab active" data-view="summary">Summary</button>
<button class="tab" data-view="logs">All logs</button>
`;
const logsView = document.createElement("div");
logsView.className = "logs-view";
logsView.appendChild(controls);
logsView.appendChild(tableContainer);
logsView.style.display = "none";
viewTabs.onclick = (e) => {
const btn = e.target.closest(".tab");
if (!btn) return;
viewTabs
.querySelectorAll(".tab")
.forEach((t) => t.classList.remove("active"));
btn.classList.add("active");
const view = btn.getAttribute("data-view");
if (view === "summary") {
summary.style.display = "";
logsView.style.display = "none";
renderSummary();
} else {
summary.style.display = "none";
logsView.style.display = "";
renderRows();
}
};
modal.appendChild(header);
modal.appendChild(viewTabs);
modal.appendChild(summary);
modal.appendChild(logsView);
container.appendChild(modal);
container.onclick = (e) => {
if (e.target === container) container.remove();
};
document.body.appendChild(container);
}
async toggleSettingsPanel() {
let panel = document.getElementById("usage-settings-panel");
if (panel) {
const btn = document.getElementById("usage-settings-btn");
if (btn) btn.classList.remove("active");
panel.remove();
return;
}
const modal = document.getElementById("usage-modal-content");
if (!modal) return;
const s = await this.settings.get();
panel = document.createElement("div");
panel.id = "usage-settings-panel";
panel.className = "settings-panel";
panel.innerHTML = `
<div class="settings-row">
<label><input type="checkbox" id="set-truncate"> Truncate prompts</label>
<input type="number" id="set-truncate-len" min="10" max="5000" class="input-w-80" />
</div>
<div class="settings-row">
<label><input type="checkbox" id="set-anon"> Anonymize prompts</label>
</div>
<div class="settings-row">
<label>Retention days <input type="number" id="set-days" min="1" max="365" class="input-w-80" /></label>
<label>Max logs <input type="number" id="set-count" min="10" max="100000" class="input-w-100" /></label>
<button class="modal-button" id="set-save">Save</button>
</div>
`;
modal.insertBefore(panel, modal.children[1]);
panel.querySelector("#set-truncate").checked = !!s.truncatePrompt;
panel.querySelector("#set-truncate-len").value = s.truncatePromptLen;
panel.querySelector("#set-anon").checked = !!s.anonymizePrompt;
panel.querySelector("#set-days").value = s.retention?.maxDays ?? "";
panel.querySelector("#set-count").value = s.retention?.maxCount ?? "";
panel.querySelector("#set-save").onclick = async () => {
const next = {
truncatePrompt: panel.querySelector("#set-truncate").checked,
truncatePromptLen:
parseInt(panel.querySelector("#set-truncate-len").value, 10) ||
Config.DEFAULT_SETTINGS.truncatePromptLen,
anonymizePrompt: panel.querySelector("#set-anon").checked,
retention: {
maxDays: (() => {
const v = panel.querySelector("#set-days").value;
const n = Number(v);
return !v || isNaN(n) || n <= 0 ? null : n;
})(),
maxCount: (() => {
const v = panel.querySelector("#set-count").value;
const n = Number(v);
return !v || isNaN(n) || n <= 0 ? null : n;
})(),
},
};
await this.settings.set(next);
alert("Settings saved. New logs will use updated settings.");
};
const btn = document.getElementById("usage-settings-btn");
if (btn) btn.classList.add("active");
}
async exportLogs(kind) {
const logs = await this.logs.getAll();
if (kind === "json") {
const blob = new Blob([JSON.stringify(logs, null, 2)], {
type: "application/json",
});
downloadBlob(
blob,
`chatgpt-usage-logs-${new Date().toISOString()}.json`
);
} else if (kind === "csv") {
const header = [
"timestamp",
"model",
"msToFirstToken",
"totalSeconds",
"conversationId",
"prompt",
];
const rows = logs.map((l) =>
[
l.timestamp,
safeStr(l.model),
l.durations?.msToFirstToken ?? "",
typeof l.durations?.msTotal === "number"
? (l.durations.msTotal / 1000).toFixed(2)
: "",
safeStr(l.conversationId),
safeStr(l.prompt),
]
.map(csvEscape)
.join(",")
);
const csv = header.join(",") + "\n" + rows.join("\n");
const blob = new Blob([csv], { type: "text/csv" });
downloadBlob(
blob,
`chatgpt-usage-logs-${new Date().toISOString()}.csv`
);
}
}
}
function createHeaderButton(ui) {
const btn = document.createElement("button");
btn.id = Config.HEADER_BUTTON_ID;
btn.type = "button";
btn.setAttribute("aria-label", "View usage logs");
btn.className = "btn relative btn-ghost text-token-text-primary mx-2";
btn.innerHTML = `
<div class="flex w-full items-center justify-center gap-1.5">
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor" xmlns="http://www.w3.org/2000/svg" class="-ms-0.5 icon">
<path d="M3 4.75C3 4.33579 3.33579 4 3.75 4H16.25C16.6642 4 17 4.33579 17 4.75C17 5.16421 16.6642 5.5 16.25 5.5H3.75C3.33579 5.5 3 5.16421 3 4.75Z"></path>
<path d="M3 10C3 9.58579 3.33579 9.25 3.75 9.25H16.25C16.6642 9.25 17 9.58579 17 10C17 10.4142 16.6642 10.75 16.25 10.75H3.75C3.33579 10.75 3 10.4142 3 10Z"></path>
<path d="M3.75 14.5C3.33579 14.5 3 14.8358 3 15.25C3 15.6642 3.33579 16 3.75 16H12.25C12.6642 16 13 15.6642 13 15.25C13 14.8358 12.6642 14.5 12.25 14.5H3.75Z"></path>
</svg>
Usage Logs
</div>`;
btn.onclick = () => ui.showModal();
return btn;
}
function injectHeaderButton(ui) {
const actionsContainer = document.getElementById(
"conversation-header-actions"
);
if (!actionsContainer) return false;
if (document.getElementById(Config.HEADER_BUTTON_ID)) return true;
const btn = createHeaderButton(ui);
if (actionsContainer.firstChild) {
actionsContainer.insertBefore(btn, actionsContainer.firstChild);
} else {
actionsContainer.appendChild(btn);
}
return true;
}
function observeHeader(ui) {
injectHeaderButton(ui);
const root = document.body || document.documentElement;
if (!root) return;
let lastInject = 0;
const observer = new MutationObserver(() => {
const now = Date.now();
if (now - lastInject < 300) return;
lastInject = now;
injectHeaderButton(ui);
});
observer.observe(root, { childList: true, subtree: true });
}
class App {
constructor() {
this.settings = new SettingsService();
this.logs = new LogsService(this.settings);
this.ui = new UiManager(this.settings, this.logs);
this.streamProcessor = new StreamProcessor(this.settings, this.logs);
this.fetchInterceptor = new FetchInterceptor(this.streamProcessor);
}
bootstrap() {
this.fetchInterceptor.install();
injectStyles();
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", () =>
observeHeader(this.ui)
);
} else {
observeHeader(this.ui);
}
GM_registerMenuCommand("View Usage Logs", () => this.ui.showModal());
}
}
const app = new App();
app.bootstrap();
})();