owlservable 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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 A Harbs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,77 @@
1
+ # owlservable
2
+
3
+ One line. See every API call your app makes.
4
+
5
+ ```js
6
+ import 'owlservable/auto'
7
+ ```
8
+
9
+ Open **http://localhost:4321** and watch requests arrive in real time.
10
+
11
+ No config. No dependencies. No build step. ~400 lines of vanilla Node.js.
12
+
13
+ ---
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ npm install owlservable
19
+ ```
20
+
21
+ ## Usage
22
+
23
+ Drop one import at the top of your entry file:
24
+
25
+ ```js
26
+ import 'owlservable/auto'
27
+
28
+ // ... rest of your app
29
+ ```
30
+
31
+ Every `fetch()`, `http.request()`, and `https.request()` your app makes will appear in the dashboard instantly.
32
+
33
+ ## Manual init
34
+
35
+ ```js
36
+ import { init } from 'owlservable'
37
+
38
+ init({
39
+ port: 4321, // default: 4321
40
+ dashboard: true, // default: true
41
+ logging: false, // log to console, default: false
42
+ })
43
+ ```
44
+
45
+ ## Dashboard
46
+
47
+ - Live request log — method, status, latency, URL
48
+ - Color-coded status: green 2xx · yellow 3xx · red 4xx/5xx
49
+ - AI token counts for OpenAI, Anthropic, Gemini, Cohere — including streaming
50
+ - Optional log persistence (1h / 24h / 7d retention)
51
+ - Clear logs button wipes memory and the log file
52
+
53
+ ## How it works
54
+
55
+ On import, `owlservable` monkey-patches `globalThis.fetch`, `node:http`, and `node:https`. Every outbound call passes through a thin wrapper that records timing and metadata, then forwards unchanged. A plain `http.createServer` serves one HTML file. Real-time updates arrive via Server-Sent Events — no WebSockets, no polling.
56
+
57
+ ## Requirements
58
+
59
+ - Node.js 18+
60
+ - Zero runtime dependencies
61
+
62
+ ---
63
+
64
+ ## Security
65
+
66
+ **Development only.** `owlservable` exits immediately when `NODE_ENV=production`. Keep it in `devDependencies` and never bundle it into a production build.
67
+
68
+ - Dashboard binds to `127.0.0.1` only — not accessible over a network
69
+ - No authentication on the dashboard port
70
+ - Request bodies up to 32 KB are captured in memory — avoid secrets in request bodies
71
+ - `Authorization` headers are not captured
72
+ - API keys passed as query params will appear in the URL log
73
+ - When persistence is enabled, `.owlservable/log.ndjson` contains full request data — treat it as sensitive
74
+
75
+ ## License
76
+
77
+ MIT
package/auto.js ADDED
@@ -0,0 +1,2 @@
1
+ import { init } from './index.js'
2
+ init()
package/dashboard.html ADDED
@@ -0,0 +1,354 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1">
6
+ <title>owlservable</title>
7
+ <link rel="icon" type="image/png" href="/owl.png">
8
+ <style>
9
+ *{box-sizing:border-box;margin:0;padding:0}
10
+ :root{--bg:#0d1117;--surface:#161b22;--border:#444d56;--text:#e6edf3;--muted:#7d8590;--green:#3fb950;--yellow:#d29922;--red:#f85149;--mono:'Cascadia Code','Fira Code',ui-monospace,monospace}
11
+ body{background:var(--bg);color:var(--text);font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;font-size:12px;height:100vh;display:flex;flex-direction:column;overflow:hidden}
12
+ body.light{--bg:#fff;--surface:#f0f0f0;--border:#bbb;--text:#111;--muted:#666}
13
+ header{display:flex;align-items:center;gap:8px;padding:11px 18px;border-bottom:1px solid var(--border);flex-shrink:0;flex-wrap:wrap}
14
+ .logo{font-family:var(--mono);font-size:14px;font-weight:700;letter-spacing:-.5px;margin-right:4px}
15
+ .dot{width:7px;height:7px;border-radius:50%;background:var(--green);animation:pulse 2s ease-in-out infinite;flex-shrink:0}
16
+ .dot.off{background:var(--red);animation:none}.dot.paused{background:var(--yellow);animation:none}
17
+ @keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
18
+ .hstats{display:flex;align-items:center;gap:14px;border-left:1px solid var(--border);border-right:1px solid var(--border);padding:0 12px;margin:0 2px}
19
+ .hstats span{color:var(--muted);white-space:nowrap}
20
+ .hstats b{color:var(--text);font-family:var(--mono);font-weight:600}
21
+ #save-ctrl{display:flex;align-items:center;gap:5px}
22
+ button{padding:4px 10px;background:transparent;border:1px solid var(--border);border-radius:5px;color:var(--muted);cursor:pointer}
23
+ button:hover{border-color:var(--text);color:var(--text)}
24
+ button.paused{border-color:var(--yellow);color:var(--yellow)}
25
+ button.active{border-color:var(--green);color:var(--green)}
26
+ main{flex:1;overflow-y:auto;min-height:0}
27
+ table{width:100%;border-collapse:collapse;table-layout:fixed}
28
+ thead tr{position:sticky;top:0;background:var(--bg);border-bottom:1px solid var(--border);z-index:1}
29
+ th,.dl{font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.07em;color:var(--muted)}
30
+ th{padding:7px 12px;text-align:left;white-space:nowrap;user-select:none}
31
+ td{padding:6px 12px;font-family:var(--mono);border-bottom:1px solid var(--border);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;vertical-align:middle}
32
+ .c-time{width:110px}.c-method{width:64px}.c-status{width:60px}.c-lat{width:76px}.c-tok{width:90px}
33
+ tr.req{cursor:pointer}
34
+ tr.req:hover td,tr.req.open td{background:var(--surface)}
35
+ .GET{color:var(--green)}.POST{color:var(--text)}.PUT,.PATCH{color:var(--yellow)}.DELETE{color:var(--red)}
36
+ .badge{display:inline-block;padding:1px 5px;border-radius:4px;font-weight:700;background:var(--surface)}
37
+ .s2{color:var(--green)}.s3{color:var(--yellow)}.s4,.s5{color:var(--red)}.s0{color:var(--muted)}
38
+ .muted{color:var(--muted)}
39
+ .tok{display:inline-flex;align-items:center;gap:3px;padding:1px 6px;border-radius:4px;font-weight:600;background:var(--surface);color:var(--green)}
40
+ .pending{display:inline-block;width:6px;height:6px;border-radius:50%;background:var(--border);animation:blink 1s ease-in-out infinite}
41
+ @keyframes blink{0%,100%{opacity:.25}50%{opacity:1}}
42
+ .new{animation:fi .3s}
43
+ @keyframes fi{from{background:var(--surface)}to{background:transparent}}
44
+ ::-webkit-scrollbar{width:5px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
45
+ tr.detail td{padding:0;border-bottom:1px solid var(--border)}
46
+ .dp{padding:12px 18px 12px 44px;background:var(--bg);display:flex;flex-wrap:wrap;gap:16px;font-family:var(--mono)}
47
+ .ds{display:flex;flex-direction:column;gap:3px;min-width:120px}
48
+ .dw{flex:1 1 100%}
49
+ .dv{word-break:break-all;white-space:normal}
50
+ .dv.url{color:var(--muted)}
51
+ .tg{display:flex;gap:6px;margin-top:2px}
52
+ .tc{background:var(--surface);border:1px solid var(--border);border-radius:5px;padding:5px 10px;text-align:center;min-width:64px}
53
+ .tc .n{font-size:14px;font-weight:700}
54
+ .tc .l{font-size:10px;color:var(--muted);text-transform:uppercase}
55
+ .tc.in .n{color:var(--text)}.tc.out .n{color:var(--green)}.tc.tot .n{color:var(--yellow)}
56
+ .empty{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;height:200px;color:var(--muted)}
57
+ .empty h2{font-weight:500;color:var(--text)}
58
+ .empty code{font-family:var(--mono);background:var(--surface);border:1px solid var(--border);padding:5px 12px;border-radius:6px}
59
+ .msg-chain{display:flex;flex-direction:column;gap:3px;margin-top:6px}
60
+ .msg{display:flex;gap:10px;align-items:flex-start}
61
+ .msg-role{flex-shrink:0;font-size:10px;font-weight:700;text-transform:uppercase;padding:1px 5px;border-radius:3px;line-height:1.8;align-self:flex-start;background:var(--bg)}
62
+ .msg-role.user{color:var(--text)}.msg-role.assistant{color:var(--green)}
63
+ .msg-role.tool,.msg-role.tool_result{color:var(--yellow)}.msg-role.system{color:var(--muted)}
64
+ .msg-content{opacity:.8;word-break:break-all;white-space:normal;flex:1;line-height:1.5}
65
+ .tool-calls{display:flex;flex-direction:column;gap:6px;margin-top:6px}
66
+ .tool-call{padding:6px 10px;background:var(--surface);border:1px solid var(--border);border-radius:5px}
67
+ .tool-name{font-weight:700;color:var(--yellow);display:block;margin-bottom:3px}
68
+ .tool-args{color:var(--muted);word-break:break-all;white-space:pre-wrap;display:block}
69
+ .res-text{white-space:pre-wrap;word-break:break-word;line-height:1.6;opacity:.85}
70
+ details.raw-body{margin-top:2px}
71
+ details.raw-body>summary{cursor:pointer;user-select:none;list-style:none;display:flex;align-items:center;gap:4px}
72
+ details.raw-body>summary::before{content:'▶';font-size:8px;transition:transform .15s}
73
+ details.raw-body[open]>summary::before{transform:rotate(90deg)}
74
+ details.raw-body>summary:hover{color:var(--text)}
75
+ .raw-pre{color:var(--muted);white-space:pre-wrap;word-break:break-all;margin-top:6px;max-height:260px;overflow-y:auto;background:var(--surface);border:1px solid var(--border);border-radius:5px;padding:8px;line-height:1.5}
76
+ </style>
77
+ </head>
78
+ <body>
79
+ <header>
80
+ <img src="/owl.png" alt="" style="width:24px;height:24px;object-fit:contain;border-radius:4px;flex-shrink:0">
81
+ <span class="logo">owlservable</span>
82
+ <button id="btn-pause" title="Pause">⏸</button>
83
+ <span class="dot" id="dot"></span>
84
+ <div class="hstats">
85
+ <span>requests <b id="n-req">0</b></span>
86
+ <span>errors <b id="n-err">0</b></span>
87
+ <span>avg <b id="n-lat">—</b></span>
88
+ <span>tokens <b id="n-tok">0</b></span>
89
+ <span>mem <b id="n-store">0/500</b></span>
90
+ </div>
91
+ <div id="save-ctrl"></div>
92
+ <button id="btn-clear" style="margin-left:auto">Clear logs</button>
93
+ <button id="btn-theme" title="Toggle theme">☀</button>
94
+ </header>
95
+ <main>
96
+ <div id="log">
97
+ <div class="empty" id="empty">
98
+ <h2>Waiting for requests…</h2>
99
+ <code>import 'owlservable/auto'</code>
100
+ </div>
101
+ <table id="tbl" style="display:none">
102
+ <thead><tr>
103
+ <th class="c-time">Time</th>
104
+ <th class="c-method">Method</th>
105
+ <th class="c-status">Status</th>
106
+ <th class="c-lat">Latency</th>
107
+ <th class="c-tok">Tokens</th>
108
+ <th class="c-url">URL</th>
109
+ </tr></thead>
110
+ <tbody id="tbody"></tbody>
111
+ </table>
112
+ </div>
113
+ </main>
114
+ <script>
115
+ (function(){
116
+ var tbody=document.getElementById('tbody'),tbl=document.getElementById('tbl'),
117
+ empty=document.getElementById('empty'),dot=document.getElementById('dot'),
118
+ nReq=document.getElementById('n-req'),nErr=document.getElementById('n-err'),
119
+ nLat=document.getElementById('n-lat'),nTok=document.getElementById('n-tok'),
120
+ nStore=document.getElementById('n-store'),
121
+ btnPause=document.getElementById('btn-pause'),
122
+ saveCtrl=document.getElementById('save-ctrl');
123
+
124
+ var paused=false,pauseBuffer=[];
125
+ var total=0,errs=0,sumMs=0,totalTok=0,storeSize=0;
126
+ var byId={};
127
+
128
+ function sc(s){if(!s)return 's0';if(s>=500)return 's5';if(s>=400)return 's4';if(s>=300)return 's3';if(s>=200)return 's2';return 's0';}
129
+ function fms(ms){if(ms==null)return'—';return ms>=1000?(ms/1000).toFixed(2)+'s':ms+'ms';}
130
+ function fn(n){if(!n)return'0';if(n>=1e6)return(n/1e6).toFixed(1)+'M';if(n>=1000)return(n/1000).toFixed(1)+'k';return''+n;}
131
+ function ftime(ts){var d=new Date(ts);return d.getHours().toString().padStart(2,'0')+':'+d.getMinutes().toString().padStart(2,'0')+':'+d.getSeconds().toString().padStart(2,'0')+'.'+(d.getMilliseconds()+'').padStart(3,'0');}
132
+ function esc(s){return(''+s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
133
+ function splitUrl(u){try{var p=new URL(u);return{host:p.host,path:p.pathname+(p.search||'')};}catch(e){return{host:'',path:u};}}
134
+
135
+ function tokHtml(r){
136
+ if(r.tokens)return'<span class="tok">'+fn(r.tokens.total)+'</span>';
137
+ if(r.metaPending)return'<span class="pending" title="parsing response…"></span>';
138
+ return'<span class="muted">—</span>';
139
+ }
140
+
141
+ function msgContent(m){
142
+ if(typeof m.content==='string')return m.content;
143
+ if(Array.isArray(m.content))return m.content.map(function(c){
144
+ if(c.type==='text')return c.text||'';
145
+ if(c.type==='tool_use')return'[tool_use: '+c.name+'('+JSON.stringify(c.input||{}).slice(0,80)+')]';
146
+ if(c.type==='tool_result'){
147
+ var v=Array.isArray(c.content)?c.content.map(function(x){return x.text||'';}).join(''):String(c.content||'');
148
+ return'[tool_result: '+v.slice(0,80)+']';
149
+ }
150
+ return'['+(c.type||'?')+']';
151
+ }).join(' ');
152
+ if(m.tool_calls)return m.tool_calls.map(function(tc){
153
+ return'[call: '+(tc.function&&tc.function.name||'?')+'('+(tc.function&&tc.function.arguments||'').slice(0,80)+')]';
154
+ }).join(', ');
155
+ return String(m.content||'');
156
+ }
157
+
158
+ function msgChainHtml(messages){
159
+ if(!messages||!messages.length)return'';
160
+ var h='<div class="ds dw"><div class="dl">Messages ('+messages.length+')</div><div class="msg-chain">';
161
+ for(var i=0;i<messages.length;i++){
162
+ var m=messages[i],role=m.role||'unknown',text=msgContent(m);
163
+ var preview=text.slice(0,300)+(text.length>300?'…':'');
164
+ h+='<div class="msg"><span class="msg-role '+esc(role)+'">'+esc(role)+'</span><span class="msg-content">'+esc(preview)+'</span></div>';
165
+ }
166
+ return h+'</div></div>';
167
+ }
168
+
169
+ function toolCallsHtml(calls){
170
+ if(!calls||!calls.length)return'';
171
+ var h='<div class="ds dw"><div class="dl">Tool calls ('+calls.length+')</div><div class="tool-calls">';
172
+ for(var i=0;i<calls.length;i++){
173
+ var tc=calls[i];
174
+ var name=(tc.function&&tc.function.name)||tc.name||'?';
175
+ var args=(tc.function&&tc.function.arguments)||(tc.input?JSON.stringify(tc.input,null,2):'');
176
+ h+='<div class="tool-call"><span class="tool-name">'+esc(name)+'</span>';
177
+ if(args)h+='<span class="tool-args">'+esc(args.slice(0,400))+(args.length>400?'…':'')+'</span>';
178
+ h+='</div>';
179
+ }
180
+ return h+'</div></div>';
181
+ }
182
+
183
+ function rawBodyHtml(label,body){
184
+ if(!body)return'';
185
+ var pretty=body;try{pretty=JSON.stringify(JSON.parse(body),null,2);}catch(_){}
186
+ return'<details class="ds dw raw-body"><summary class="dl">'+esc(label)+'</summary><pre class="raw-pre">'+esc(pretty)+'</pre></details>';
187
+ }
188
+
189
+ function detailHtml(r){
190
+ var h='<div class="dp">';
191
+ h+='<div class="ds dw"><div class="dl">URL</div><div class="dv url">'+esc(r.url||'—')+'</div></div>';
192
+ if(r.error)h+='<div class="ds"><div class="dl">Error</div><div class="dv" style="color:var(--red)">'+esc(r.error)+'</div></div>';
193
+ if(r.model)h+='<div class="ds"><div class="dl">Model</div><div class="dv">'+esc(r.model)+'</div></div>';
194
+ var reqJson=null,resJson=null;
195
+ try{if(r.reqBody)reqJson=JSON.parse(r.reqBody);}catch(_){}
196
+ try{if(r.resBody)resJson=JSON.parse(r.resBody);}catch(_){}
197
+ if(reqJson&&reqJson.messages)h+=msgChainHtml(reqJson.messages);
198
+ var toolCalls=null;
199
+ if(resJson){
200
+ if(resJson.choices&&resJson.choices[0]&&resJson.choices[0].message)
201
+ toolCalls=resJson.choices[0].message.tool_calls||null;
202
+ if(!toolCalls&&Array.isArray(resJson.content))
203
+ toolCalls=resJson.content.filter(function(c){return c.type==='tool_use';});
204
+ }
205
+ if(toolCalls&&toolCalls.length)h+=toolCallsHtml(toolCalls);
206
+ var responseText=null;
207
+ if(resJson){
208
+ if(resJson.choices&&resJson.choices[0]&&resJson.choices[0].message)
209
+ responseText=resJson.choices[0].message.content;
210
+ if(!responseText&&Array.isArray(resJson.content)){
211
+ var tb=resJson.content.find(function(c){return c.type==='text';});
212
+ if(tb)responseText=tb.text;
213
+ }
214
+ }
215
+ if(responseText&&typeof responseText==='string'&&responseText.trim())
216
+ h+='<div class="ds dw"><div class="dl">Response</div><div class="dv res-text">'+esc(responseText.slice(0,800))+(responseText.length>800?'…':'')+'</div></div>';
217
+ if(r.tokens){
218
+ h+='<div class="ds dw"><div class="dl">Token usage</div><div class="tg">';
219
+ h+='<div class="tc in"><div class="n">'+fn(r.tokens.input)+'</div><div class="l">Input</div></div>';
220
+ h+='<div class="tc out"><div class="n">'+fn(r.tokens.output)+'</div><div class="l">Output</div></div>';
221
+ h+='<div class="tc tot"><div class="n">'+fn(r.tokens.total)+'</div><div class="l">Total</div></div>';
222
+ h+='</div></div>';
223
+ }
224
+ h+=rawBodyHtml('Request body',r.reqBody);
225
+ h+=rawBodyHtml('Response body',r.resBody);
226
+ return h+'</div>';
227
+ }
228
+
229
+ function addRow(r,prepend){
230
+ total++;if(!r.status||r.error)errs++;sumMs+=r.latency||0;
231
+ if(r.tokens)totalTok+=r.tokens.total||0;
232
+ storeSize=Math.min(storeSize+1,500);
233
+ nReq.textContent=total;nErr.textContent=errs;
234
+ nLat.textContent=fms(Math.round(sumMs/total));
235
+ nTok.textContent=fn(totalTok);nStore.textContent=storeSize+'/500';
236
+ var method=r.method||'GET',parts=splitUrl(r.url||'');
237
+ var sTxt=r.status||(r.error?'ERR':'—');
238
+ var tr=document.createElement('tr');tr.className='req new';tr.dataset.id=r.id;
239
+ tr.innerHTML=
240
+ '<td class="c-time muted">'+ftime(r.timestamp)+'</td>'+
241
+ '<td class="c-method"><span class="'+esc(method)+'">'+esc(method)+'</span></td>'+
242
+ '<td class="c-status"><span class="badge '+sc(r.status)+'">'+esc(sTxt)+'</span></td>'+
243
+ '<td class="c-lat muted">'+fms(r.latency||0)+'</td>'+
244
+ '<td class="c-tok">'+tokHtml(r)+'</td>'+
245
+ '<td class="c-url" title="'+esc(r.url||'')+'"><span class="muted">'+esc(parts.host)+'</span>'+esc(parts.path)+'</td>';
246
+ var dtd=document.createElement('td');dtd.colSpan=6;dtd.innerHTML=detailHtml(r);
247
+ var dtr=document.createElement('tr');dtr.className='detail';dtr.style.display='none';dtr.appendChild(dtd);
248
+ tr.addEventListener('click',function(){
249
+ var open=dtr.style.display!=='none';
250
+ dtr.style.display=open?'none':'';tr.classList.toggle('open',!open);
251
+ });
252
+ byId[r.id]={tr:tr,dtr:dtr,data:r};
253
+ if(prepend){tbody.insertBefore(dtr,tbody.firstChild);tbody.insertBefore(tr,tbody.firstChild);}
254
+ else{tbody.appendChild(tr);tbody.appendChild(dtr);}
255
+ if(tbl.style.display==='none'){tbl.style.display='';empty.style.display='none';}
256
+ }
257
+
258
+ function applyUpdate(id,patch){
259
+ var e=byId[id];if(!e)return;
260
+ Object.assign(e.data,patch);
261
+ if(patch.tokens){
262
+ totalTok+=patch.tokens.total||0;
263
+ nTok.textContent=fn(totalTok);
264
+ e.tr.querySelector('.c-tok').innerHTML=tokHtml(e.data);
265
+ }else if('metaPending' in patch&&!patch.metaPending){
266
+ e.tr.querySelector('.c-tok').innerHTML=tokHtml(e.data);
267
+ }
268
+ e.dtr.querySelector('td').innerHTML=detailHtml(e.data);
269
+ }
270
+
271
+ function reset(){
272
+ tbody.innerHTML='';byId={};
273
+ total=errs=sumMs=totalTok=storeSize=0;
274
+ nReq.textContent=nErr.textContent=nTok.textContent='0';
275
+ nLat.textContent='—';nStore.textContent='0/500';
276
+ tbl.style.display='none';empty.style.display='';
277
+ }
278
+
279
+ function renderSave(cfg){
280
+ if(!saveCtrl)return;
281
+ var active=cfg&&cfg.enabled?cfg.retention:null;
282
+ var h='<span class="muted">save</span>';
283
+ [{ms:3600000,label:'1h'},{ms:86400000,label:'24h'},{ms:604800000,label:'7d'}].forEach(function(o){
284
+ h+='<button data-ms="'+o.ms+'"'+(active===o.ms?' class="active"':'')+'>'+o.label+'</button>';
285
+ });
286
+ saveCtrl.innerHTML=h;
287
+ saveCtrl.querySelectorAll('button[data-ms]').forEach(function(b){
288
+ b.addEventListener('click',function(){
289
+ var ms=+b.dataset.ms,isActive=b.classList.contains('active');
290
+ b.disabled=true;b.textContent='…';
291
+ var body=isActive?{action:'disable'}:{action:'enable',retention:ms};
292
+ fetch('/api/save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})
293
+ .then(function(r){return r.json();}).then(function(d){renderSave(d.save);})
294
+ .catch(function(){renderSave(cfg);});
295
+ });
296
+ });
297
+ }
298
+
299
+
300
+ document.getElementById('btn-clear').addEventListener('click',function(){
301
+ fetch('/api/clear',{method:'POST'}).catch(function(){});
302
+ });
303
+
304
+ function handleMsg(msg){
305
+ if(msg.type==='init'){
306
+ msg.requests.slice().reverse().forEach(function(r){addRow(r,false);});
307
+ renderSave(msg.save);
308
+ }else if(msg.type==='request'){
309
+ addRow(msg.record,true);
310
+ }else if(msg.type==='update'){
311
+ applyUpdate(msg.id,msg.patch);
312
+ }else if(msg.type==='saveConfig'){
313
+ renderSave(msg.config);
314
+ }else if(msg.type==='reload'){
315
+ reset();
316
+ if(msg.requests)msg.requests.slice().reverse().forEach(function(r){addRow(r,false);});
317
+ if(msg.save)renderSave(msg.save);
318
+ }
319
+ }
320
+
321
+ btnPause.addEventListener('click',function(){
322
+ paused=!paused;
323
+ if(paused){
324
+ btnPause.textContent='⏵';btnPause.title='Resume';dot.classList.add('paused');
325
+ }else{
326
+ pauseBuffer.forEach(handleMsg);pauseBuffer=[];
327
+ btnPause.textContent='⏸';btnPause.title='Pause';dot.classList.remove('paused');
328
+ }
329
+ });
330
+
331
+ document.getElementById('btn-theme').addEventListener('click',function(){
332
+ var light=document.body.classList.toggle('light');
333
+ this.textContent=light?'☾':'☀';
334
+ });
335
+
336
+ function connect(){
337
+ var es=new EventSource('/events');
338
+ es.addEventListener('open',function(){dot.classList.remove('off');});
339
+ es.addEventListener('error',function(){dot.classList.add('off');es.close();setTimeout(connect,2000);});
340
+ es.addEventListener('message',function(e){
341
+ var msg;try{msg=JSON.parse(e.data);}catch(_){return;}
342
+ if(paused&&(msg.type==='request'||msg.type==='update')){
343
+ pauseBuffer.push(msg);
344
+ btnPause.title='Resume ('+pauseBuffer.length+' buffered)';
345
+ return;
346
+ }
347
+ handleMsg(msg);
348
+ });
349
+ }
350
+ connect();
351
+ })();
352
+ </script>
353
+ </body>
354
+ </html>
package/index.js ADDED
@@ -0,0 +1,398 @@
1
+ import { EventEmitter } from 'node:events'
2
+ import { fileURLToPath } from 'node:url'
3
+ import * as http from 'node:http'
4
+ import * as https from 'node:https'
5
+ import * as fs from 'node:fs'
6
+ import * as path from 'node:path'
7
+
8
+ const __dir = path.dirname(fileURLToPath(import.meta.url))
9
+
10
+ const MAX_REQ_BODY = 32 * 1024
11
+ const MAX_RES_BODY = 64 * 1024
12
+ const MAX_PARSE = 512 * 1024
13
+
14
+ const emitter = new EventEmitter()
15
+ emitter.setMaxListeners(100)
16
+
17
+ const requests = []
18
+ let nextId = 1
19
+
20
+ function addRequest(entry) {
21
+ try {
22
+ const r = { id: nextId++, ...entry, timestamp: Date.now() }
23
+ requests.push(r)
24
+ if (requests.length > 500) requests.shift()
25
+ emitter.emit('request', r)
26
+ if (saveState.enabled) persistLine(r)
27
+ return r
28
+ } catch (_) {}
29
+ }
30
+
31
+ function updateRequest(id, patch) {
32
+ try {
33
+ const r = requests.find(r => r.id === id)
34
+ if (r) {
35
+ Object.assign(r, patch)
36
+ emitter.emit('update', { id, patch })
37
+ if (saveState.enabled && (patch.tokens || patch.resBody)) persistLine(r)
38
+ }
39
+ } catch (_) {}
40
+ }
41
+
42
+ function getRequests() { try { return requests.slice() } catch (_) { return [] } }
43
+
44
+ function clearRequests() {
45
+ requests.splice(0)
46
+ if (saveState.enabled) try { fs.writeFileSync(saveState.filePath, '') } catch (_) {}
47
+ emitter.emit('reload', { requests: [], save: getSaveInfo() })
48
+ }
49
+
50
+ function extractAiMeta(body) {
51
+ try {
52
+ const j = JSON.parse(body)
53
+ if (j.usage?.total_tokens != null)
54
+ return { model: j.model || null, tokens: { input: j.usage.prompt_tokens || 0, output: j.usage.completion_tokens || 0, total: j.usage.total_tokens } }
55
+ if (j.usage?.input_tokens != null) {
56
+ const i = j.usage.input_tokens || 0, o = j.usage.output_tokens || 0
57
+ return { model: j.model || null, tokens: { input: i, output: o, total: i + o } }
58
+ }
59
+ if (j.usageMetadata?.totalTokenCount != null) {
60
+ const m = j.usageMetadata
61
+ return { model: j.modelVersion || null, tokens: { input: m.promptTokenCount || 0, output: m.candidatesTokenCount || 0, total: m.totalTokenCount } }
62
+ }
63
+ if (j.meta?.billed_units) {
64
+ const b = j.meta.billed_units, i = b.input_tokens || 0, o = b.output_tokens || 0
65
+ if (i || o) return { model: null, tokens: { input: i, output: o, total: i + o } }
66
+ }
67
+ } catch (_) {}
68
+ return null
69
+ }
70
+
71
+ function extractAiMetaFromSSE(raw) {
72
+ try {
73
+ let input = 0, output = 0, model = null
74
+ for (const line of raw.split('\n')) {
75
+ if (!line.startsWith('data: ') || line === 'data: [DONE]') continue
76
+ try {
77
+ const j = JSON.parse(line.slice(6))
78
+ if (j.usage?.total_tokens != null)
79
+ return { model: j.model || model, tokens: { input: j.usage.prompt_tokens || 0, output: j.usage.completion_tokens || 0, total: j.usage.total_tokens } }
80
+ if (j.type === 'message_start' && j.message) {
81
+ if (j.message.usage?.input_tokens) input = j.message.usage.input_tokens
82
+ if (j.message.model) model = j.message.model
83
+ }
84
+ if (j.type === 'message_delta' && j.usage?.output_tokens)
85
+ output = j.usage.output_tokens
86
+ } catch (_) {}
87
+ }
88
+ if (input || output) return { model, tokens: { input, output, total: input + output } }
89
+ } catch (_) {}
90
+ return null
91
+ }
92
+
93
+ function capStr(s, max) {
94
+ if (!s || typeof s !== 'string') return null
95
+ return s.length <= max ? s : s.slice(0, max) + '…'
96
+ }
97
+
98
+ function bodyFromInit(b) {
99
+ try {
100
+ if (typeof b === 'string') return capStr(b, MAX_REQ_BODY)
101
+ if (b instanceof URLSearchParams) return capStr(b.toString(), MAX_REQ_BODY)
102
+ if (b instanceof ArrayBuffer) return capStr(Buffer.from(b).toString('utf8'), MAX_REQ_BODY)
103
+ if (ArrayBuffer.isView(b)) return capStr(Buffer.from(b.buffer, b.byteOffset, b.byteLength).toString('utf8'), MAX_REQ_BODY)
104
+ } catch (_) {}
105
+ return null
106
+ }
107
+
108
+ let patched = false
109
+
110
+ function handleJsonResponse(record, getText) {
111
+ getText()
112
+ .then(body => {
113
+ const meta = extractAiMeta(body)
114
+ const update = { metaPending: false, resBody: capStr(body, MAX_RES_BODY) }
115
+ if (meta) Object.assign(update, meta)
116
+ updateRequest(record.id, update)
117
+ })
118
+ .catch(() => updateRequest(record.id, { metaPending: false }))
119
+ }
120
+
121
+ function handleSseResponse(record, getText) {
122
+ getText()
123
+ .then(body => {
124
+ const meta = extractAiMetaFromSSE(body)
125
+ const update = { metaPending: false }
126
+ if (meta) Object.assign(update, meta)
127
+ updateRequest(record.id, update)
128
+ })
129
+ .catch(() => updateRequest(record.id, { metaPending: false }))
130
+ }
131
+
132
+ function patchFetch() {
133
+ const orig = globalThis.fetch
134
+ globalThis.fetch = async function interceptedFetch(input, init) {
135
+ let url = 'unknown', method = 'GET', reqBody = null
136
+ try {
137
+ url = input instanceof Request ? input.url : String(input)
138
+ method = (init?.method || (input instanceof Request ? input.method : null) || 'GET').toUpperCase()
139
+ const rawBody = init?.body ?? null
140
+ if (typeof rawBody === 'string' && rawBody.includes('"stream"')) {
141
+ try {
142
+ const parsed = JSON.parse(rawBody)
143
+ if (parsed.stream === true && !parsed.stream_options?.include_usage) {
144
+ parsed.stream_options = { ...parsed.stream_options, include_usage: true }
145
+ init = { ...init, body: JSON.stringify(parsed) }
146
+ }
147
+ } catch (_) {}
148
+ }
149
+ reqBody = bodyFromInit(init?.body ?? null)
150
+ } catch (_) {}
151
+
152
+ const start = Date.now()
153
+ try {
154
+ const res = await orig.call(this, input, init)
155
+ const ct = res.headers.get('content-type') || ''
156
+ const isJson = ct.includes('application/json')
157
+ const isSse = ct.includes('text/event-stream')
158
+ const r = addRequest({ url, method, status: res.status, latency: Date.now() - start, reqBody, metaPending: isJson || isSse })
159
+ if (r && isJson) handleJsonResponse(r, () => res.clone().text())
160
+ if (r && isSse) handleSseResponse(r, () => res.clone().text())
161
+ return res
162
+ } catch (err) {
163
+ try { addRequest({ url, method, status: 0, latency: Date.now() - start, reqBody, error: err.message }) } catch (_) {}
164
+ throw err
165
+ }
166
+ }
167
+ }
168
+
169
+ function patchHttpModule(mod, protocol) {
170
+ const orig = mod.request
171
+ mod.request = function interceptedRequest(...args) {
172
+ const start = Date.now()
173
+ let url = 'unknown', method = 'GET'
174
+ try {
175
+ const first = args[0]
176
+ if (typeof first === 'string') { url = first; method = args[1]?.method || 'GET' }
177
+ else if (first instanceof URL) { url = first.toString(); method = args[1]?.method || 'GET' }
178
+ else if (first && typeof first === 'object') {
179
+ url = `${protocol}://${first.hostname || first.host || 'localhost'}${first.port ? ':' + first.port : ''}${first.path || '/'}`
180
+ method = first.method || 'GET'
181
+ }
182
+ } catch (_) {}
183
+
184
+ const req = orig.apply(mod, args)
185
+ const reqChunks = []; let reqSize = 0
186
+ const origWrite = req.write
187
+ req.write = function(chunk, encoding, cb) {
188
+ try {
189
+ if (reqSize < MAX_REQ_BODY) {
190
+ const s = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : typeof chunk === 'string' ? chunk : ''
191
+ if (s) { reqChunks.push(s); reqSize += s.length }
192
+ }
193
+ } catch (_) {}
194
+ return origWrite.call(this, chunk, encoding, cb)
195
+ }
196
+
197
+ try {
198
+ req.once('response', res => {
199
+ const reqBody = reqSize > 0 ? reqChunks.join('').slice(0, MAX_REQ_BODY) : null
200
+ const ct = res.headers['content-type'] || ''
201
+ const isJson = ct.includes('application/json')
202
+ const isSse = ct.includes('text/event-stream')
203
+ const r = addRequest({ url, method: method.toUpperCase(), status: res.statusCode, latency: Date.now() - start, reqBody, metaPending: isJson || isSse })
204
+ if (r && (isJson || isSse)) {
205
+ try {
206
+ const chunks = []; let size = 0
207
+ res.on('data', chunk => { try { if (size < MAX_PARSE) { chunks.push(chunk); size += chunk.length } } catch (_) {} })
208
+ const getText = () => new Promise((resolve, reject) => {
209
+ res.once('end', () => { try { resolve(Buffer.concat(chunks).toString('utf8')) } catch (e) { reject(e) } })
210
+ res.once('error', reject)
211
+ })
212
+ if (isJson) handleJsonResponse(r, getText)
213
+ if (isSse) handleSseResponse(r, getText)
214
+ } catch (_) { updateRequest(r.id, { metaPending: false }) }
215
+ }
216
+ })
217
+ req.once('error', err => {
218
+ try { addRequest({ url, method: method.toUpperCase(), status: 0, latency: Date.now() - start, error: err.message }) } catch (_) {}
219
+ })
220
+ } catch (_) {}
221
+
222
+ return req
223
+ }
224
+ }
225
+
226
+ function patch() {
227
+ if (patched) return; patched = true
228
+ try { if (typeof globalThis.fetch === 'function') patchFetch() } catch (_) {}
229
+ try { patchHttpModule(http, 'http') } catch (_) {}
230
+ try { patchHttpModule(https, 'https') } catch (_) {}
231
+ }
232
+
233
+ const saveState = { enabled: false, filePath: null, retention: null }
234
+
235
+ function getSaveInfo() {
236
+ if (!saveState.enabled) return { enabled: false }
237
+ return { enabled: true, filePath: saveState.filePath, relPath: path.relative(process.cwd(), saveState.filePath).replace(/\\/g, '/'), retention: saveState.retention }
238
+ }
239
+
240
+ function persistLine(r) {
241
+ try { fs.appendFileSync(saveState.filePath, JSON.stringify(r) + '\n') } catch (_) {}
242
+ }
243
+
244
+ function parseLog(raw, cutoff) {
245
+ const byId = new Map()
246
+ for (const line of raw.split('\n')) {
247
+ try { const r = JSON.parse(line); if (r.id && r.timestamp > cutoff) byId.set(r.id, r) } catch (_) {}
248
+ }
249
+ return [...byId.values()].sort((a, b) => a.id - b.id)
250
+ }
251
+
252
+ function pruneFile() {
253
+ if (!saveState.filePath) return
254
+ try {
255
+ const raw = fs.readFileSync(saveState.filePath, 'utf8').trim()
256
+ if (!raw) return
257
+ const entries = parseLog(raw, Date.now() - saveState.retention)
258
+ const out = entries.map(JSON.stringify).join('\n')
259
+ fs.writeFileSync(saveState.filePath, out + (out ? '\n' : ''))
260
+ } catch (_) {}
261
+ }
262
+
263
+ function enableSave(retentionMs) {
264
+ const filePath = path.join(process.cwd(), '.owlservable', 'log.ndjson')
265
+ const dir = path.dirname(filePath)
266
+ try { fs.mkdirSync(dir, { recursive: true }) } catch (_) {}
267
+ try { const gi = path.join(dir, '.gitignore'); if (!fs.existsSync(gi)) fs.writeFileSync(gi, '*\n') } catch (_) {}
268
+ try { fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ save: true, retention: retentionMs })) } catch (_) {}
269
+ saveState.enabled = true
270
+ saveState.filePath = filePath
271
+ saveState.retention = retentionMs
272
+ try {
273
+ const raw = fs.readFileSync(filePath, 'utf8').trim()
274
+ if (raw) {
275
+ const loaded = parseLog(raw, Date.now() - retentionMs)
276
+ for (const r of loaded) {
277
+ if (!requests.find(x => x.id === r.id)) requests.push(r)
278
+ if (r.id >= nextId) nextId = r.id + 1
279
+ }
280
+ if (requests.length > 500) requests.splice(0, requests.length - 500)
281
+ }
282
+ } catch (_) {}
283
+ pruneFile()
284
+ emitter.emit('reload', { requests: getRequests(), save: getSaveInfo() })
285
+ }
286
+
287
+ function disableSave() {
288
+ saveState.enabled = false
289
+ try { fs.unlinkSync(path.join(process.cwd(), '.owlservable', 'config.json')) } catch (_) {}
290
+ emitter.emit('saveConfig', getSaveInfo())
291
+ }
292
+
293
+ setInterval(() => { if (saveState.enabled) pruneFile() }, 3_600_000).unref()
294
+
295
+ let server = null
296
+ let DASHBOARD_HTML = null
297
+
298
+ function getDashboard() {
299
+ if (!DASHBOARD_HTML) DASHBOARD_HTML = fs.readFileSync(path.join(__dir, 'dashboard.html'), 'utf8')
300
+ return DASHBOARD_HTML
301
+ }
302
+
303
+ function readBody(req) {
304
+ return new Promise((resolve, reject) => {
305
+ let body = ''
306
+ req.on('data', c => { body += c })
307
+ req.on('end', () => resolve(body))
308
+ req.on('error', reject)
309
+ })
310
+ }
311
+
312
+ function startDashboard(port) {
313
+ if (server) return
314
+
315
+ server = http.createServer((req, res) => {
316
+ try {
317
+ const urlPath = req.url?.split('?')[0] || '/'
318
+
319
+ if (urlPath === '/events') {
320
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' })
321
+ const send = d => { try { res.write('data: ' + JSON.stringify(d) + '\n\n') } catch (_) {} }
322
+ send({ type: 'init', requests: getRequests(), save: getSaveInfo() })
323
+ const onReq = r => send({ type: 'request', record: r })
324
+ const onUpdate = ({ id, patch }) => send({ type: 'update', id, patch })
325
+ const onSave = cfg => send({ type: 'saveConfig', config: cfg })
326
+ const onReload = data => send({ type: 'reload', ...data })
327
+ emitter.on('request', onReq)
328
+ emitter.on('update', onUpdate)
329
+ emitter.on('saveConfig', onSave)
330
+ emitter.on('reload', onReload)
331
+ req.on('close', () => {
332
+ emitter.off('request', onReq)
333
+ emitter.off('update', onUpdate)
334
+ emitter.off('saveConfig', onSave)
335
+ emitter.off('reload', onReload)
336
+ })
337
+ return
338
+ }
339
+
340
+ if (urlPath === '/api/save' && req.method === 'POST') {
341
+ readBody(req).then(body => {
342
+ const { action, retention } = JSON.parse(body)
343
+ if (action === 'enable' && retention > 0) enableSave(retention)
344
+ else if (action === 'disable') disableSave()
345
+ res.writeHead(200, { 'Content-Type': 'application/json' })
346
+ res.end(JSON.stringify({ ok: true, save: getSaveInfo() }))
347
+ }).catch(e => { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: e.message })) })
348
+ return
349
+ }
350
+
351
+ if (urlPath === '/api/clear' && req.method === 'POST') {
352
+ clearRequests()
353
+ res.writeHead(200, { 'Content-Type': 'application/json' })
354
+ res.end(JSON.stringify({ ok: true }))
355
+ return
356
+ }
357
+
358
+ if (urlPath === '/owl.png') {
359
+ try {
360
+ const data = fs.readFileSync(path.join(__dir, 'owl.png'))
361
+ res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'public, max-age=86400' })
362
+ res.end(data)
363
+ } catch (_) { res.writeHead(404); res.end() }
364
+ return
365
+ }
366
+
367
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
368
+ res.end(getDashboard())
369
+ } catch (_) { try { if (!res.headersSent) { res.writeHead(500); res.end() } } catch (_) {} }
370
+ })
371
+
372
+ server.on('error', e => { if (e.code === 'EADDRINUSE') console.warn('[owlservable] Port ' + port + ' in use — dashboard not started') })
373
+ server.listen(port, '127.0.0.1', () => console.log('[owlservable] Dashboard → http://localhost:' + port))
374
+ }
375
+
376
+ let initialized = false
377
+
378
+ export function init({ port = 4321, dashboard = true, logging = false, save = false, retention = 86_400_000 } = {}) {
379
+ if (initialized) return; initialized = true
380
+ if (process.env.NODE_ENV === 'production') {
381
+ console.warn('[owlservable] Refusing to start in NODE_ENV=production — remove owlservable from your production bundle')
382
+ return
383
+ }
384
+ try { patch() } catch (_) {}
385
+ if (!save) {
386
+ try {
387
+ const cfg = JSON.parse(fs.readFileSync(path.join(process.cwd(), '.owlservable', 'config.json'), 'utf8'))
388
+ if (cfg.save) { save = true; retention = cfg.retention || retention }
389
+ } catch (_) {}
390
+ }
391
+ if (save) try { enableSave(retention) } catch (_) {}
392
+ if (dashboard) try { startDashboard(port) } catch (_) {}
393
+ if (logging) {
394
+ emitter.on('request', r => {
395
+ try { console.log('[owlservable]', r.method, r.url, '→', r.status || r.error || '?', '(' + (r.latency || 0) + 'ms)') } catch (_) {}
396
+ })
397
+ }
398
+ }
package/owl.png ADDED
Binary file
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "owlservable",
3
+ "version": "0.1.0",
4
+ "description": "Minimalist Observability Platform. Zero config, zero dependencies.",
5
+ "type": "module",
6
+ "main": "index.js",
7
+ "exports": {
8
+ ".": "./index.js",
9
+ "./auto": "./auto.js"
10
+ },
11
+ "files": [
12
+ "index.js",
13
+ "auto.js",
14
+ "dashboard.html",
15
+ "owl.png"
16
+ ],
17
+ "engines": {
18
+ "node": ">=18"
19
+ },
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/aaharbaugh/Owlservable"
23
+ },
24
+ "homepage": "https://github.com/aaharbaugh/Owlservable#readme",
25
+ "bugs": {
26
+ "url": "https://github.com/aaharbaugh/Owlservable/issues"
27
+ },
28
+ "license": "MIT"
29
+ }