@observyze/sdk 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/src/client.ts ADDED
@@ -0,0 +1,578 @@
1
+ /**
2
+ * Observyze SDK Client
3
+ * Main entry point for instrumenting AI applications
4
+ */
5
+
6
+ import { Trace } from './trace'
7
+ import { TraceStatus } from './types'
8
+ import type { ClientConfig, ResolvedClientConfig } from './types'
9
+
10
+ /**
11
+ * Default configuration values
12
+ */
13
+ const DEFAULT_CONFIG: Partial<ClientConfig> = {
14
+ endpoint: 'http://localhost:3001',
15
+ batchSize: 100,
16
+ flushInterval: 5000,
17
+ enableAutoInstrumentation: true,
18
+ debug: false,
19
+ dryRun: false,
20
+ enablePiiRedaction: true,
21
+ hallucinationThreshold: 0.8,
22
+ safetyThreshold: 0.9,
23
+ evalEndpoint: process.env.EVAL_ENDPOINT || (process.env.NODE_ENV === 'production' ? 'https://api.observyze.com' : 'http://localhost:3000'),
24
+ enableCircuitBreaker: true
25
+ }
26
+
27
+ /**
28
+ * Main SDK client for Observyze
29
+ */
30
+ export class ObservyzeClient {
31
+ private config: ResolvedClientConfig
32
+ private traceBuffer: Trace[] = []
33
+ private flushTimer: NodeJS.Timeout | null = null
34
+ private isShuttingDown: boolean = false
35
+ private readonly MAX_QUEUE_SIZE = 1000
36
+ private readonly RETRY_DELAYS = [1000, 2000, 4000, 8000, 16000, 30000] // ms: 1s → 2s → 4s → 8s → 16s → 30s
37
+
38
+ constructor(config: ClientConfig) {
39
+ // Validate required fields
40
+ if (!config.apiKey) {
41
+ throw new Error('Observyze SDK: apiKey is required')
42
+ }
43
+
44
+ // Apply defaults
45
+ this.config = {
46
+ ...DEFAULT_CONFIG,
47
+ ...config,
48
+ organizationId: config.organizationId || '',
49
+ projectId: config.projectId || ''
50
+ } as ResolvedClientConfig
51
+
52
+ // Start flush timer
53
+ this.startFlushTimer()
54
+
55
+ // Log initialization
56
+ if (this.config.debug) {
57
+ console.log('[Observyze SDK] Initialized with config:', {
58
+ endpoint: this.config.endpoint,
59
+ batchSize: this.config.batchSize,
60
+ flushInterval: this.config.flushInterval,
61
+ dryRun: this.config.dryRun
62
+ })
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Start a new trace
68
+ */
69
+ startTrace(name: string, metadata?: Record<string, any>): Trace {
70
+ const trace = new Trace(
71
+ name,
72
+ this.config.organizationId,
73
+ this.config.projectId
74
+ )
75
+
76
+ if (metadata) {
77
+ trace.setMetadataAll(metadata)
78
+ }
79
+
80
+ // Auto-end and buffer trace when it's ended
81
+ const originalEnd = trace.end.bind(trace)
82
+ trace.end = (status: TraceStatus = TraceStatus.SUCCESS) => {
83
+ originalEnd(status)
84
+ this.bufferTrace(trace)
85
+ }
86
+
87
+ return trace
88
+ }
89
+
90
+ /**
91
+ * Buffer a completed trace for batch sending
92
+ */
93
+ private bufferTrace(trace: Trace): void {
94
+ if (!trace.isEnded) {
95
+ if (this.config.debug) {
96
+ console.warn('[Observyze SDK] Attempted to buffer a trace that has not ended')
97
+ }
98
+ return
99
+ }
100
+
101
+ // Check if queue is at max capacity
102
+ if (this.traceBuffer.length >= this.MAX_QUEUE_SIZE) {
103
+ if (this.config.debug) {
104
+ console.warn(`[Observyze SDK] Queue at max capacity (${this.MAX_QUEUE_SIZE}), dropping oldest trace`)
105
+ }
106
+ // Drop oldest trace to make room
107
+ this.traceBuffer.shift()
108
+ }
109
+
110
+ this.traceBuffer.push(trace)
111
+
112
+ if (this.config.debug) {
113
+ console.log(`[Observyze SDK] Buffered trace ${trace.id} (${this.traceBuffer.length}/${this.config.batchSize})`)
114
+ }
115
+
116
+ // Flush if buffer is full
117
+ if (this.traceBuffer.length >= this.config.batchSize) {
118
+ this.flush().catch(err => {
119
+ console.error('[Observyze SDK] Error flushing buffer:', err)
120
+ })
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Start the auto-flush timer
126
+ */
127
+ private startFlushTimer(): void {
128
+ if (this.flushTimer) {
129
+ clearInterval(this.flushTimer)
130
+ }
131
+
132
+ this.flushTimer = setInterval(() => {
133
+ if (this.traceBuffer.length > 0) {
134
+ this.flush().catch(err => {
135
+ console.error('[Observyze SDK] Error in auto-flush:', err)
136
+ })
137
+ }
138
+ }, this.config.flushInterval)
139
+
140
+ // Don't keep the process alive just for the timer
141
+ if (this.flushTimer.unref) {
142
+ this.flushTimer.unref()
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Flush all buffered traces to the Ingestion Service
148
+ */
149
+ async flush(): Promise<void> {
150
+ if (this.traceBuffer.length === 0) {
151
+ return
152
+ }
153
+
154
+ const tracesToSend = this.traceBuffer.splice(0, this.config.batchSize)
155
+
156
+ if (this.config.debug) {
157
+ console.log(`[Observyze SDK] Flushing ${tracesToSend.length} traces`)
158
+ }
159
+
160
+ // In dry-run mode, just log and return
161
+ if (this.config.dryRun) {
162
+ if (this.config.debug) {
163
+ console.log('[Observyze SDK] Dry-run mode: traces not sent')
164
+ }
165
+ return
166
+ }
167
+
168
+ try {
169
+ await this.sendWithRetry(tracesToSend)
170
+ } catch (error) {
171
+ // On final failure, put traces back in buffer (up to max queue size)
172
+ const remainingSpace = this.MAX_QUEUE_SIZE - this.traceBuffer.length
173
+ if (remainingSpace > 0) {
174
+ this.traceBuffer.unshift(...tracesToSend.slice(0, remainingSpace))
175
+ if (this.config.debug) {
176
+ console.log(`[Observyze SDK] Re-queued ${Math.min(tracesToSend.length, remainingSpace)} traces after failure`)
177
+ }
178
+ } else {
179
+ if (this.config.debug) {
180
+ console.warn(`[Observyze SDK] Queue full, dropped ${tracesToSend.length} traces`)
181
+ }
182
+ }
183
+
184
+ if (this.config.debug) {
185
+ console.error('[Observyze SDK] Failed to send traces after retries:', error)
186
+ }
187
+
188
+ throw error
189
+ }
190
+ }
191
+
192
+ /**
193
+ * Send traces with exponential backoff retry
194
+ */
195
+ private async sendWithRetry(traces: Trace[]): Promise<void> {
196
+ let lastError: Error | null = null
197
+
198
+ for (let attempt = 0; attempt < this.RETRY_DELAYS.length + 1; attempt++) {
199
+ try {
200
+ const response = await fetch(`${this.config.endpoint}/api/v1/ingest/batch`, {
201
+ method: 'POST',
202
+ headers: {
203
+ 'Content-Type': 'application/json',
204
+ 'Authorization': `Bearer ${this.config.apiKey}`
205
+ },
206
+ body: JSON.stringify({
207
+ traces: traces.map(trace => {
208
+ const json = trace.toJSON();
209
+ if (this.config.enablePiiRedaction) {
210
+ json.spans = this.sanitizePII(json.spans);
211
+ }
212
+ return json;
213
+ })
214
+ })
215
+ })
216
+
217
+ if (!response.ok) {
218
+ const errorBody = await response.text()
219
+ throw new Error(`Ingestion failed: ${response.status} ${errorBody}`)
220
+ }
221
+
222
+ if (this.config.debug) {
223
+ console.log(`[Observyze SDK] Successfully sent ${traces.length} traces${attempt > 0 ? ` (after ${attempt} retries)` : ''}`)
224
+ }
225
+
226
+ return // Success
227
+ } catch (error) {
228
+ lastError = error as Error
229
+
230
+ // If this is the last attempt, break and throw
231
+ if (attempt >= this.RETRY_DELAYS.length) {
232
+ break
233
+ }
234
+
235
+ // Calculate delay with exponential backoff
236
+ const delay = this.RETRY_DELAYS[attempt]
237
+
238
+ if (this.config.debug) {
239
+ console.warn(`[Observyze SDK] Attempt ${attempt + 1} failed, retrying in ${delay}ms...`, error)
240
+ }
241
+
242
+ // Wait before retrying
243
+ await new Promise(resolve => setTimeout(resolve, delay))
244
+ }
245
+ }
246
+
247
+ // All retries exhausted
248
+ throw lastError || new Error('Failed to send traces after all retries')
249
+ }
250
+
251
+ /**
252
+ * Shutdown the SDK and flush remaining traces
253
+ */
254
+ async shutdown(): Promise<void> {
255
+ if (this.isShuttingDown) {
256
+ return
257
+ }
258
+
259
+ this.isShuttingDown = true
260
+
261
+ if (this.config.debug) {
262
+ console.log('[Observyze SDK] Shutting down...')
263
+ }
264
+
265
+ // Stop the flush timer
266
+ if (this.flushTimer) {
267
+ clearInterval(this.flushTimer)
268
+ this.flushTimer = null
269
+ }
270
+
271
+ // Flush remaining traces
272
+ try {
273
+ await this.flush()
274
+ } catch (error) {
275
+ console.error('[Observyze SDK] Error during shutdown flush:', error)
276
+ }
277
+
278
+ if (this.config.debug) {
279
+ console.log('[Observyze SDK] Shutdown complete')
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Get current buffer size
285
+ */
286
+ get bufferSize(): number {
287
+ return this.traceBuffer.length
288
+ }
289
+
290
+ /**
291
+ * Get SDK configuration
292
+ */
293
+ getConfig(): Readonly<ResolvedClientConfig> {
294
+ return { ...this.config }
295
+ }
296
+
297
+ /**
298
+ * Wrap an LLM client (OpenAI, Anthropic) to enable auto-instrumentation
299
+ *
300
+ * @example
301
+ * ```typescript
302
+ * import OpenAI from 'openai'
303
+ * import { ObservyzeClient } from '@observyze/sdk'
304
+ *
305
+ * const nw = new ObservyzeClient({ apiKey: 'your-api-key' })
306
+ * const openai = new OpenAI({ apiKey: 'openai-key' })
307
+ *
308
+ * // Wrap the client to enable auto-instrumentation
309
+ * nw.wrap(openai)
310
+ *
311
+ * // All calls are now automatically traced
312
+ * const response = await openai.chat.completions.create({
313
+ * model: 'gpt-4',
314
+ * messages: [{ role: 'user', content: 'Hello!' }]
315
+ * })
316
+ * ```
317
+ */
318
+ wrap<T>(client: T): T {
319
+ const { wrap: wrapClient } = require('./instrumentation')
320
+ return wrapClient(client, this)
321
+ }
322
+
323
+ /**
324
+ * Sync local agent .history file to Observyze cloud
325
+ * Parses JSON/NDJSON agent history and sends to ingestion endpoint.
326
+ */
327
+ async syncLocalHistory(filePath: string): Promise<void> {
328
+ try {
329
+ if (typeof process === 'undefined' || !process.versions?.node) {
330
+ throw new Error('syncLocalHistory is only available in Node.js environments')
331
+ }
332
+
333
+ const fs = require('fs')
334
+ const path = require('path')
335
+ const fullPath = path.resolve(process.cwd(), filePath)
336
+
337
+ if (!fs.existsSync(fullPath)) {
338
+ throw new Error(`History file not found: ${fullPath}`)
339
+ }
340
+
341
+ const content = fs.readFileSync(fullPath, 'utf-8')
342
+ let items: any[] = []
343
+
344
+ // Handle NDJSON or plain JSON array
345
+ try {
346
+ items = JSON.parse(content)
347
+ } catch (e) {
348
+ items = content.split('\n').filter((l: string) => l.trim()).map((l: string) => JSON.parse(l))
349
+ }
350
+
351
+ if (!Array.isArray(items)) {
352
+ items = [items]
353
+ }
354
+
355
+ if (this.config.debug) {
356
+ console.log(`[Observyze SDK] Syncing ${items.length} traces from ${filePath}`)
357
+ }
358
+
359
+ // Send directly via api utilizing standard format
360
+ for (let i = 0; i < items.length; i += this.config.batchSize) {
361
+ const batch = items.slice(i, i + this.config.batchSize)
362
+
363
+ const response = await fetch(`${this.config.endpoint}/api/v1/ingest/batch`, {
364
+ method: 'POST',
365
+ headers: {
366
+ 'Content-Type': 'application/json',
367
+ 'Authorization': `Bearer ${this.config.apiKey}`
368
+ },
369
+ body: JSON.stringify({ traces: batch })
370
+ })
371
+
372
+ if (!response.ok) {
373
+ const errorBody = await response.text()
374
+ throw new Error(`Batch sync failed: ${response.status} ${errorBody}`)
375
+ }
376
+
377
+ if (this.config.debug) {
378
+ console.log(`[Observyze SDK] Synced batch of ${batch.length} traces from local history`)
379
+ }
380
+ }
381
+ } catch (err) {
382
+ console.error('[Observyze SDK] Failed to sync local history:', err)
383
+ throw err
384
+ }
385
+ }
386
+
387
+ /**
388
+ * Phase 5: PII Redaction (Compliance & RBAC)
389
+ * Recursively sanitize strings to mask common PII before transmitting to the cloud.
390
+ */
391
+ private sanitizePII(data: any): any {
392
+ if (data === null || data === undefined) return data;
393
+
394
+ if (typeof data === 'string') {
395
+ return data
396
+ .replace(/\b[\w\.-]+@[\w\.-]+\.\w{2,4}\b/gi, '[EMAIL_REDACTED]') // Emails
397
+ .replace(/\b\d{3}-\d{2}-\d{4}\b/g, '[SSN_REDACTED]') // SSN
398
+ .replace(/\b(?:\d[ -]*?){13,16}\b/g, '[CC_REDACTED]'); // Credit Cards
399
+ }
400
+ if (Array.isArray(data)) {
401
+ return data.map(item => this.sanitizePII(item));
402
+ }
403
+ if (typeof data === 'object') {
404
+ const sanitized: any = {};
405
+ for (const [k, v] of Object.entries(data)) {
406
+ sanitized[k] = this.sanitizePII(v);
407
+ }
408
+ return sanitized;
409
+ }
410
+ return data;
411
+ }
412
+
413
+ /**
414
+ * Phase 4: Autonomous Circuit Breakers (Requirement 4.1)
415
+ * Evaluate a trace or text for hallucination in real-time.
416
+ * If hallucination score > hallucinationThreshold, the SDK blocks execution.
417
+ */
418
+ async checkGuardrails(content: string | any): Promise<{ pass: boolean, score: number, reason?: string, safetyScore?: number }> {
419
+ if (!this.config.enableCircuitBreaker) {
420
+ return { pass: true, score: 0 }
421
+ }
422
+
423
+ try {
424
+ if (this.config.debug) {
425
+ console.log(`[Observyze Guardrail] Analyzing payload for hallucination anomalies...`)
426
+ }
427
+
428
+ const evalEndpoint = this.config.evalEndpoint
429
+ const payload = typeof content === 'string'
430
+ ? { text: content, organization_id: this.config.organizationId }
431
+ : { trace: content, organization_id: this.config.organizationId }
432
+
433
+ const controller = new AbortController()
434
+ const timeout = setTimeout(() => controller.abort(), 5000)
435
+
436
+ let evalResult: any = null
437
+
438
+ try {
439
+ const response = await fetch(`${evalEndpoint}/api/v1/evaluate/hallucination`, {
440
+ method: 'POST',
441
+ headers: {
442
+ 'Content-Type': 'application/json',
443
+ 'Authorization': `Bearer ${this.config.apiKey}`
444
+ },
445
+ body: JSON.stringify(payload),
446
+ signal: controller.signal
447
+ })
448
+
449
+ clearTimeout(timeout)
450
+
451
+ if (response.ok) {
452
+ evalResult = await response.json()
453
+ } else if (response.status === 404) {
454
+ if (this.config.debug) {
455
+ console.log('[Observyze Guardrail] Eval endpoint not found, using fallback')
456
+ }
457
+ }
458
+ } catch (fetchError: any) {
459
+ clearTimeout(timeout)
460
+ if (fetchError.name === 'AbortError') {
461
+ if (this.config.debug) {
462
+ console.warn('[Observyze Guardrail] Evaluation timed out after 5s')
463
+ }
464
+ } else if (this.config.debug) {
465
+ console.warn('[Observyze Guardrail] Evaluation request failed:', fetchError.message)
466
+ }
467
+ }
468
+
469
+ let hallucinationScore = 0
470
+ let safetyScore = 0
471
+
472
+ if (evalResult) {
473
+ hallucinationScore = evalResult.score ?? evalResult.hallucination_score ?? 0
474
+ safetyScore = evalResult.safety_score ?? 0
475
+ } else {
476
+ if (this.config.debug) {
477
+ console.log('[Observyze Guardrail] No eval result, using conservative threshold')
478
+ }
479
+ hallucinationScore = 0.5
480
+ }
481
+
482
+ const hallThreshold = this.config.hallucinationThreshold
483
+ const safeThreshold = this.config.safetyThreshold
484
+
485
+ if (hallucinationScore >= hallThreshold) {
486
+ if (this.config.debug) {
487
+ console.warn(`[Observyze Guardrail] Hallucination circuit breached! Score: ${hallucinationScore.toFixed(2)} >= ${hallThreshold}`)
488
+ }
489
+ return {
490
+ pass: false,
491
+ score: hallucinationScore,
492
+ safetyScore,
493
+ reason: `Hallucination score ${hallucinationScore.toFixed(2)} exceeds threshold ${hallThreshold}. Execution blocked for human review.`
494
+ }
495
+ }
496
+
497
+ if (safetyScore >= safeThreshold) {
498
+ if (this.config.debug) {
499
+ console.warn(`[Observyze Guardrail] Safety circuit breached! Score: ${safetyScore.toFixed(2)} >= ${safeThreshold}`)
500
+ }
501
+ return {
502
+ pass: false,
503
+ score: hallucinationScore,
504
+ safetyScore,
505
+ reason: `Safety score ${safetyScore.toFixed(2)} exceeds threshold ${safeThreshold}. Execution blocked for safety review.`
506
+ }
507
+ }
508
+
509
+ return { pass: true, score: hallucinationScore, safetyScore }
510
+
511
+ } catch (err) {
512
+ console.error('[Observyze Guardrail] Failed to evaluate:', err)
513
+ return { pass: true, score: 0 }
514
+ }
515
+ }
516
+
517
+ /**
518
+ * Phase 4: Autonomous Circuit Breakers
519
+ * Execute an agent action wrapped with the Circuit Breaker.
520
+ * Pauses execution if hallucination score >= hallucinationThreshold and requests human review.
521
+ * @throws Error when execution is blocked by circuit breaker
522
+ */
523
+ async executeWithCircuitBreaker<T>(agentExecution: () => Promise<T>, traceContext?: any): Promise<T> {
524
+ if (!this.config.enableCircuitBreaker) {
525
+ return await agentExecution()
526
+ }
527
+
528
+ const guardResult = await this.checkGuardrails(traceContext || 'execution context')
529
+
530
+ if (!guardResult.pass) {
531
+ const error = new Error(`[Observyze] Execution Blocked by Autonomous Circuit Breaker. ` +
532
+ `Hallucination: ${guardResult.score.toFixed(2)}, Safety: ${(guardResult.safetyScore ?? 0).toFixed(2)}. ` +
533
+ `Reason: ${guardResult.reason}. Human approval required before agent can continue.`)
534
+
535
+ if (this.config.debug) {
536
+ console.error('[Observyze CircuitBreaker] Execution blocked:', error.message)
537
+ }
538
+
539
+ throw error
540
+ }
541
+
542
+ if (this.config.debug) {
543
+ console.log(`[Observyze CircuitBreaker] Execution allowed. Hallucination: ${guardResult.score.toFixed(2)}, Safety: ${(guardResult.safetyScore ?? 0).toFixed(2)}`)
544
+ }
545
+
546
+ return await agentExecution()
547
+ }
548
+
549
+ /**
550
+ * Phase 4: Bug Bounty Protocol (Automated) (Requirement 4.2)
551
+ * Automatically shard persistent failure cases to external security researcher endpoints (e.g. HackerOne wrapper)
552
+ */
553
+ async reportBugBounty(traceId: string, securityEndpoint: string, failureContext: any): Promise<void> {
554
+ try {
555
+ if (this.config.debug) {
556
+ console.log(`[Observyze SDK] Sharding persistent failure case ${traceId} to Bug Bounty Protocol endpoint...`);
557
+ }
558
+
559
+ // Dispatch failure case context to security webhook
560
+ await fetch(securityEndpoint, {
561
+ method: 'POST',
562
+ headers: { 'Content-Type': 'application/json' },
563
+ body: JSON.stringify({
564
+ alert: 'persistent_failure_sharded',
565
+ trace_id: traceId,
566
+ context: failureContext,
567
+ timestamp: new Date().toISOString()
568
+ })
569
+ });
570
+
571
+ if (this.config.debug) {
572
+ console.log(`[Observyze SDK] Bug Bounty payload successfully transmitted.`);
573
+ }
574
+ } catch (err) {
575
+ console.error('[Observyze Bug Bounty] Failed to shard failure case:', err);
576
+ }
577
+ }
578
+ }
package/src/index.ts ADDED
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Observyze Node.js SDK
3
+ * AI Observability & Governance Platform
4
+ */
5
+
6
+ export { ObservyzeClient } from './client'
7
+ export { Trace, Span } from './trace'
8
+ export { SpanType, TraceStatus } from './types'
9
+ export type { ClientConfig, ResolvedClientConfig } from './types'
10
+
11
+ // Export instrumentation utilities
12
+ export { wrap, wrapOpenAI, wrapAnthropic } from './instrumentation'
13
+ export type { SupportedClient } from './instrumentation'
14
+
15
+ // Re-export types from @observyze/types for convenience
16
+ export type {
17
+ Span as SpanData,
18
+ Trace as TraceData,
19
+ SpanError,
20
+ TokenUsage
21
+ } from '@observyze/types'