-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathusage-log.js
More file actions
276 lines (256 loc) · 11.8 KB
/
Copy pathusage-log.js
File metadata and controls
276 lines (256 loc) · 11.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
// Usage log: one JSON line per call, appended to a private GitHub repo
// (events/<service>/<YYYY-MM-DD>.jsonl), read back by /admin/usage.
//
// The same file is copied into each Fizzl service. It needs two settings:
// USAGE_LOG_TOKEN fine-grained GitHub token, Contents read/write on the log repo only
// USAGE_LOG_REPO owner/name of the log repo (default Fizzl13/usage-log)
// USAGE_LOG_SALT optional secret for the visitor code (default: the token)
// Without a token it does nothing. Writing never delays or breaks a request:
// events are queued and flushed in the background, one commit per flush.
'use strict';
const crypto = require('node:crypto');
const DEFAULT_REPO = 'Fizzl13/usage-log';
const API = 'https://api.github.com';
const MAX_TEXT = 300;
// Keeps inputs readable and bounded: long strings are cut, objects are
// serialised and cut, nothing nested deeper than needed.
function clip(value, max = MAX_TEXT) {
if (value === undefined || value === null) return value;
if (typeof value === 'string') return value.length > max ? `${value.slice(0, max)}…` : value;
if (typeof value === 'number' || typeof value === 'boolean') return value;
let text;
try {
text = JSON.stringify(value);
} catch {
return '[unserialisable]';
}
return text.length > max ? `${text.slice(0, max)}…` : value;
}
function clipAll(obj) {
if (!obj || typeof obj !== 'object') return obj;
const out = {};
for (const [k, v] of Object.entries(obj)) if (v !== undefined) out[k] = clip(v);
return out;
}
const NETWORK_NAMES = {
'eip155:8453': 'base',
'eip155:84532': 'base-sepolia',
'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp': 'solana',
'solana:EtWTRABZaYq6iMfeYKouRu166VU2xqa1': 'solana-devnet',
};
function decodeBase64Json(value) {
if (!value) return null;
try {
return JSON.parse(Buffer.from(String(value), 'base64').toString('utf8'));
} catch {
return null;
}
}
// What was paid, from the request's payment header (amount, network) and the
// response's settlement header (transaction, payer). Null when not paid.
function paymentOf(req, res) {
if (res.locals && res.locals.mppPayment) return res.locals.mppPayment; // paid over MPP (mpp-pay.js)
const settlement = decodeBase64Json(res.getHeader('PAYMENT-RESPONSE') || res.getHeader('X-PAYMENT-RESPONSE'));
if (!settlement || settlement.success === false) return null;
const payload = decodeBase64Json(req.headers['payment-signature'] || req.headers['x-payment']);
const accepted = payload && (payload.accepted || payload.requirements);
const network = settlement.network || (accepted && accepted.network) || null;
const atomic = accepted && (accepted.amount || accepted.maxAmountRequired);
return {
usd: atomic !== undefined && atomic !== null && /^\d+$/.test(String(atomic)) ? Number(atomic) / 1e6 : null,
network: NETWORK_NAMES[network] || network,
payer: settlement.payer || null,
tx: settlement.transaction || null,
};
}
// Who is calling, in a few words and without personal data: "browser" for a
// web browser, otherwise the first product token of the User-Agent (e.g.
// "SmitheryBot/1.0", "python-requests/2.32", "node"). No IP addresses.
function agentOf(userAgent) {
const ua = String(userAgent || '').trim();
if (!ua) return 'none';
const bot = /([A-Za-z0-9._-]*(?:bot|crawler|spider|scan|monitor)[A-Za-z0-9._-]*)(?:\/[\w.-]+)?/i.exec(ua);
if (bot) return clip(bot[0], 60);
if (/^Mozilla\//.test(ua) && /(Chrome|Safari|Firefox|Edg)\//.test(ua)) return 'browser';
return clip(ua.split(/[\s;(]/)[0] || ua, 60);
}
// A short, stable code per caller IP, so repeat visitors can be told apart
// without keeping their address: HMAC-SHA256 with a secret, cut to 12 hex
// characters. Without the secret the code can't be turned back into an IP.
function visitorOf(ip, secret) {
if (!ip || !secret) return undefined;
const address = String(ip).replace(/^::ffff:/, '');
return crypto.createHmac('sha256', secret).update(address).digest('hex').slice(0, 12);
}
function createUsageLog({ service, env = process.env, fetchFn = globalThis.fetch, now = () => new Date(), log = console } = {}) {
const token = env.USAGE_LOG_TOKEN;
const repo = env.USAGE_LOG_REPO || DEFAULT_REPO;
const visitorSecret = env.USAGE_LOG_SALT || token;
const queue = [];
let flushing = null;
let warned = false;
const headers = {
authorization: `Bearer ${token}`,
accept: 'application/vnd.github+json',
'x-github-api-version': '2022-11-28',
'user-agent': `usage-log/${service}`,
};
// Appends lines to one file: read (for the sha), write, retry on a race.
async function append(path, lines) {
for (let attempt = 0; attempt < 4; attempt++) {
const url = `${API}/repos/${repo}/contents/${path}`;
const current = await fetchFn(url, { headers });
let sha;
let text = '';
if (current.status === 200) {
const file = await current.json();
sha = file.sha;
text = Buffer.from(file.content || '', 'base64').toString('utf8');
} else if (current.status !== 404) {
throw new Error(`read ${path}: HTTP ${current.status}`);
}
const body = {
message: `${service}: ${lines.length} call${lines.length === 1 ? '' : 's'}`,
content: Buffer.from(text + lines.join('\n') + '\n').toString('base64'),
...(sha ? { sha } : {}),
};
const put = await fetchFn(url, { method: 'PUT', headers: { ...headers, 'content-type': 'application/json' }, body: JSON.stringify(body) });
if (put.status === 200 || put.status === 201) return;
if (put.status !== 409 && put.status !== 422) throw new Error(`write ${path}: HTTP ${put.status}`);
await new Promise((r) => setTimeout(r, 250 * (attempt + 1)));
}
throw new Error(`write ${path}: gave up after retries`);
}
async function flush() {
if (flushing) return flushing;
flushing = (async () => {
while (queue.length) {
const batch = queue.splice(0, queue.length);
const byFile = new Map();
for (const event of batch) {
const path = `events/${service}/${event.t.slice(0, 10)}.jsonl`;
if (!byFile.has(path)) byFile.set(path, []);
byFile.get(path).push(JSON.stringify(event));
}
for (const [path, lines] of byFile) {
try {
await append(path, lines);
} catch (err) {
log.error(`[usage-log] ${err.message}; ${lines.length} event(s) dropped`);
}
}
}
})().finally(() => {
flushing = null;
});
return flushing;
}
function record(event) {
if (!token) {
if (!warned) log.warn('[usage-log] USAGE_LOG_TOKEN not set: calls are not logged');
warned = true;
return;
}
queue.push({ t: now().toISOString(), service, ...event, input: clipAll(event.input), result: clipAll(event.result) });
flush();
}
// Express middleware. describe(req, res, body) returns { route, input, result, via, payment? }
// for calls worth logging, or null to skip (static files, health, probes).
// A 402 is logged as a quote (the caller saw the price), with payment_failed
// when the request did carry a payment that was refused.
function middleware(describe) {
return (req, res, next) => {
if (req.method === 'OPTIONS' || req.method === 'HEAD') return next();
const started = Date.now();
let body;
const json = res.json.bind(res);
res.json = (b) => {
body = b;
return json(b);
};
// Responses written without res.json (e.g. the MCP transport): keep the
// first 64 kB so a JSON body can still be read.
const chunks = [];
let size = 0;
const keep = (chunk, encoding) => {
if (!chunk || size > 65536 || typeof chunk === 'function') return;
// The MCP transport writes Uint8Arrays, not Buffers: String() on those gives "123,34,…".
const buf = Buffer.isBuffer(chunk) ? chunk
: chunk instanceof Uint8Array ? Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength)
: Buffer.from(String(chunk), typeof encoding === 'string' ? encoding : 'utf8');
size += buf.length;
if (size <= 65536) chunks.push(buf);
};
const write = res.write.bind(res);
const end = res.end.bind(res);
res.write = (chunk, ...rest) => { keep(chunk, rest[0]); return write(chunk, ...rest); };
res.end = (chunk, ...rest) => { keep(chunk, rest[0]); return end(chunk, ...rest); };
res.on('finish', () => {
const quote = res.statusCode === 402;
if (body === undefined && chunks.length) {
const text = Buffer.concat(chunks).toString('utf8');
try {
body = JSON.parse(text);
} catch {
// An MCP reply sent as an event stream: the JSON is on the last "data:" line.
const data = text.match(/^data: ?(.*)$/gm);
if (data) {
try { body = JSON.parse(data[data.length - 1].replace(/^data: ?/, '')); } catch { /* not JSON */ }
}
}
}
let described;
try {
described = describe(req, res, body);
} catch {
described = null;
}
if (!described) return;
// describe() may supply the payment itself (MCP carries it in _meta).
const payment = quote ? null : described.payment !== undefined ? described.payment : paymentOf(req, res);
// An MPP credential (Authorization: Payment …, mpp-pay.js) counts as an offered payment too.
const mppOffered = /(?:^|,)\s*Payment\s/i.test(String(req.headers.authorization || ''));
const offered = Boolean(req.headers['payment-signature'] || req.headers['x-payment']) || mppOffered;
record({
route: described.route,
via: described.via || 'http',
status: res.statusCode,
ms: Date.now() - started,
paid: Boolean(payment),
...(payment || {}),
...(quote ? { quote: true, ...(offered ? { payment_failed: true, ...(mppOffered ? { protocol: 'mpp' } : {}) } : {}) } : {}),
agent: agentOf(req.headers['user-agent']),
visitor: visitorOf(req.ip || (req.socket && req.socket.remoteAddress), visitorSecret),
input: described.input,
result: quote ? undefined : described.result,
});
});
next();
};
}
return { enabled: Boolean(token), record, middleware, flush, repo };
}
// The JSON-RPC tools/call in an MCP request body, as { tool, args }, or null.
function mcpToolCall(body) {
const calls = Array.isArray(body) ? body : [body];
const call = calls.find((c) => c && c.method === 'tools/call');
return call ? { tool: call.params && call.params.name, args: call.params && call.params.arguments } : null;
}
// An x402 payment made inside an MCP tool call: the amount from the request's
// _meta["x402/payment"], the settlement from the result's _meta["x402/payment-response"].
function mcpPayment(requestBody, responseBody) {
const call = (Array.isArray(requestBody) ? requestBody : [requestBody]).find((c) => c && c.method === 'tools/call');
const reply = (Array.isArray(responseBody) ? responseBody : [responseBody]).find((r) => r && r.result);
const settlement = reply && reply.result && reply.result._meta && reply.result._meta['x402/payment-response'];
if (!call || !settlement || settlement.success === false) return null;
const paid = call.params && call.params._meta && call.params._meta['x402/payment'];
const accepted = paid && paid.accepted;
const network = settlement.network || (accepted && accepted.network) || null;
return {
usd: accepted && /^\d+$/.test(String(accepted.amount)) ? Number(accepted.amount) / 1e6 : null,
network: NETWORK_NAMES[network] || network,
payer: settlement.payer || null,
tx: settlement.transaction || null,
};
}
module.exports = { createUsageLog, paymentOf, mcpToolCall, mcpPayment, clip, agentOf, visitorOf };