@agentuity/runtime 0.1.31 → 0.1.32

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/middleware.ts CHANGED
@@ -29,6 +29,7 @@ import * as runtimeConfig from './_config';
29
29
  import { getSessionEventProvider } from './_services';
30
30
  import { internal } from './logger/internal';
31
31
  import { STREAM_DONE_PROMISE_KEY, IS_STREAMING_RESPONSE_KEY } from './handlers/sse';
32
+ import { loadBuildMetadata } from './_metadata';
32
33
 
33
34
  const SESSION_HEADER = 'x-session-id';
34
35
  const THREAD_HEADER = 'x-thread-id';
@@ -302,6 +303,8 @@ export function createOtelMiddleware() {
302
303
  },
303
304
  },
304
305
  async (span): Promise<void> => {
306
+ // Track request duration from the SDK's perspective
307
+ const requestStartTime = performance.now();
305
308
  const sctx = span.spanContext();
306
309
  const sessionId = sctx?.traceId ? `sess_${sctx.traceId}` : generateId('sess');
307
310
 
@@ -321,6 +324,7 @@ export function createOtelMiddleware() {
321
324
 
322
325
  if (projectId) traceState = traceState.set('pid', projectId);
323
326
  if (orgId) traceState = traceState.set('oid', orgId);
327
+ if (deploymentId) traceState = traceState.set('did', deploymentId);
324
328
  if (isDevMode) traceState = traceState.set('d', '1');
325
329
 
326
330
  // Update the active context with the new trace state
@@ -353,8 +357,40 @@ export function createOtelMiddleware() {
353
357
  const sessionEventProvider = getSessionEventProvider();
354
358
  if (sessionEventProvider) {
355
359
  try {
356
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
357
- const routeId = (c as any).var?.routeId || '';
360
+ // Look up routeId from build metadata by matching method and path
361
+ // We need to do this here because the router wrapper hasn't run yet
362
+ const metadata = loadBuildMetadata();
363
+ const methodUpper = c.req.method.toUpperCase();
364
+
365
+ // Normalize paths: trim trailing slashes for consistent matching
366
+ const normalizePath = (p: string) => {
367
+ const decoded = decodeURIComponent(p);
368
+ return decoded.endsWith('/') && decoded.length > 1 ? decoded.slice(0, -1) : decoded;
369
+ };
370
+ const requestPath = normalizePath(c.req.path);
371
+
372
+ // Helper to check if requestPath ends with routePath at a segment boundary
373
+ // e.g., "/api/translate" matches "/translate" but "/api/translate-v2" does not
374
+ const matchesAtSegmentBoundary = (reqPath: string, routePath: string) => {
375
+ if (reqPath === routePath) return true;
376
+ if (!reqPath.endsWith(routePath)) return false;
377
+ // Check that the character before the match is a path separator
378
+ const charBeforeMatch = reqPath[reqPath.length - routePath.length - 1];
379
+ return charBeforeMatch === '/';
380
+ };
381
+
382
+ // Try matching by exact normalized path first
383
+ let route = metadata?.routes?.find(
384
+ (r) => r.method.toUpperCase() === methodUpper && normalizePath(r.path) === requestPath
385
+ );
386
+ // Fall back to segment-boundary matching (handles /api/translate matching /translate)
387
+ if (!route) {
388
+ route = metadata?.routes?.find(
389
+ (r) => r.method.toUpperCase() === methodUpper && matchesAtSegmentBoundary(requestPath, normalizePath(r.path))
390
+ );
391
+ }
392
+ const routeId = route?.id || '';
393
+
358
394
  await sessionEventProvider.start({
359
395
  id: sessionId,
360
396
  threadId: thread.id,
@@ -418,14 +454,27 @@ export function createOtelMiddleware() {
418
454
  }
419
455
  };
420
456
 
457
+ // Track state for finalization
458
+ let responseStatus = 200;
459
+ let errorMessage: string | undefined;
460
+ let handlerDurationMs = 0;
461
+ // Track whether span should be ended in finally block (false for streaming - ended in waitUntil)
462
+ let shouldEndSpanInFinally = true;
463
+
421
464
  try {
465
+ internal.info('[request] %s %s - handler starting (session: %s)', method, url.pathname, sessionId);
466
+
422
467
  await next();
423
468
 
469
+ // Capture timing immediately after next() returns - this is when the handler completed
470
+ // This is the HTTP response time we want to report (excludes waitUntil/finalization)
471
+ handlerDurationMs = performance.now() - requestStartTime;
472
+
473
+ internal.info('[request] %s %s - handler completed in %sms (session: %s)', method, url.pathname, handlerDurationMs.toFixed(2), sessionId);
474
+
424
475
  // Check if this is a streaming response that needs deferred finalization
425
476
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
426
- const streamDone = (c as any).get(STREAM_DONE_PROMISE_KEY) as
427
- | Promise<void>
428
- | undefined;
477
+ const streamDone = (c as any).get(STREAM_DONE_PROMISE_KEY) as Promise<void> | undefined;
429
478
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
430
479
  const isStreaming = Boolean((c as any).get(IS_STREAMING_RESPONSE_KEY));
431
480
 
@@ -433,38 +482,15 @@ export function createOtelMiddleware() {
433
482
  // or if the response status indicates an error
434
483
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
435
484
  const honoError = (c as any).error as Error | undefined;
436
- const responseStatus = c.res?.status ?? 200;
485
+ responseStatus = c.res?.status ?? 200;
437
486
  const isError = honoError || responseStatus >= 500;
438
487
 
439
- if (isStreaming && streamDone) {
440
- // Defer session/thread saving until stream completes
441
- // This ensures thread state changes made during streaming are persisted
442
- internal.info(
443
- '[session] deferring session/thread save until streaming completes (session %s)',
444
- sessionId
445
- );
488
+ internal.info('[request] %s %s - status: %d, streaming: %s, error: %s (session: %s)',
489
+ method, url.pathname, responseStatus, isStreaming, isError, sessionId);
446
490
 
447
- handler.waitUntil(async () => {
448
- try {
449
- await streamDone;
450
- internal.info(
451
- '[session] stream completed, now saving session/thread (session %s)',
452
- sessionId
453
- );
454
- } catch (ex) {
455
- // Stream ended with an error/abort; still try to persist the latest state
456
- internal.info(
457
- '[session] stream ended with error, still saving state: %s',
458
- ex
459
- );
460
- }
461
- await finalizeSession();
462
- });
463
-
464
- span.setStatus({ code: SpanStatusCode.OK });
465
- } else if (isError) {
466
- // Hono caught an error or response is 5xx - report as error
467
- const errorMessage = honoError
491
+ if (isError) {
492
+ // Capture error message for finalization
493
+ errorMessage = honoError
468
494
  ? (honoError.stack ?? honoError.message)
469
495
  : `HTTP ${responseStatus}`;
470
496
  span.setStatus({
@@ -474,26 +500,203 @@ export function createOtelMiddleware() {
474
500
  if (honoError) {
475
501
  span.recordException(honoError);
476
502
  }
477
- await finalizeSession(responseStatus, errorMessage);
478
503
  } else {
479
- // Non-streaming success: save session/thread synchronously
480
- await finalizeSession();
481
504
  span.setStatus({ code: SpanStatusCode.OK });
482
505
  }
506
+
507
+ // For streaming responses, defer everything until stream completes
508
+ if (isStreaming && streamDone) {
509
+ internal.info('[request] %s %s - streaming response, deferring finalization (session: %s)',
510
+ method, url.pathname, sessionId);
511
+
512
+ // For streaming, we end the span inside waitUntil after setting attributes
513
+ shouldEndSpanInFinally = false;
514
+
515
+ // Capture pending promises BEFORE adding finalization waitUntil to avoid deadlock
516
+ const pendingPromises = handler.getPendingSnapshot();
517
+ const hasPendingTasks = pendingPromises.length > 0;
518
+
519
+ if (hasPendingTasks) {
520
+ internal.info('[request] %s %s - %d pending waitUntil tasks to wait for after stream (session: %s)',
521
+ method, url.pathname, pendingPromises.length, sessionId);
522
+ }
523
+
524
+ // Capture values needed for span attributes (responseStatus already captured above)
525
+ const capturedResponseStatus = responseStatus;
526
+ const capturedErrorMessage = errorMessage;
527
+
528
+ // Use waitUntil to handle stream completion and finalization
529
+ // This runs AFTER the response is sent to the client
530
+ // Note: We intentionally do NOT use noSpan here - the waitUntil span helps
531
+ // track the streaming finalization work in telemetry
532
+ handler.waitUntil(async () => {
533
+ // Track if stream ended with error so we can update finalization status
534
+ let streamError: unknown = undefined;
535
+
536
+ try {
537
+ await streamDone;
538
+ internal.info('[request] %s %s - stream completed (session: %s)', method, url.pathname, sessionId);
539
+ } catch (ex) {
540
+ streamError = ex;
541
+ internal.info('[request] %s %s - stream ended with error: %s (session: %s)',
542
+ method, url.pathname, ex, sessionId);
543
+ }
544
+
545
+ // Record duration now that stream is complete - set attributes BEFORE ending span
546
+ const streamDurationMs = performance.now() - requestStartTime;
547
+ const durationNs = Math.round(streamDurationMs * 1_000_000);
548
+ internal.info('[request] %s %s - recording stream duration: %sms (session: %s)',
549
+ method, url.pathname, streamDurationMs.toFixed(2), sessionId);
550
+
551
+ // Determine final status - use stream error if present
552
+ const finalStatus = streamError ? 500 : capturedResponseStatus;
553
+ const finalErrorMessage = streamError
554
+ ? (streamError instanceof Error ? (streamError.stack ?? streamError.message) : String(streamError))
555
+ : capturedErrorMessage;
556
+
557
+ try {
558
+ // Wait for pending tasks (evals, etc.) captured BEFORE this waitUntil was added
559
+ if (hasPendingTasks) {
560
+ internal.info('[request] %s %s - waiting for %d pending waitUntil tasks (session: %s)',
561
+ method, url.pathname, pendingPromises.length, sessionId);
562
+ const logger = c.get('logger');
563
+ await handler.waitForPromises(pendingPromises, logger, sessionId);
564
+ internal.info('[request] %s %s - all waitUntil tasks complete (session: %s)', method, url.pathname, sessionId);
565
+ }
566
+
567
+ // Finalize session after stream completes and evals finish
568
+ await finalizeSession(finalStatus >= 500 ? finalStatus : undefined, finalErrorMessage);
569
+ internal.info('[request] %s %s - stream session finalization complete (session: %s)', method, url.pathname, sessionId);
570
+ } finally {
571
+ // Set span attributes and end span AFTER all work is done
572
+ span.setAttribute('@agentuity/request.duration', durationNs);
573
+ span.setAttribute('http.status_code', finalStatus);
574
+
575
+ // Set span status based on whether there was an error
576
+ if (streamError) {
577
+ span.setStatus({
578
+ code: SpanStatusCode.ERROR,
579
+ message: finalErrorMessage ?? 'Stream ended with error',
580
+ });
581
+ if (streamError instanceof Error) {
582
+ span.recordException(streamError);
583
+ }
584
+ } else {
585
+ span.setStatus({ code: SpanStatusCode.OK });
586
+ }
587
+
588
+ span.end();
589
+ internal.info('[request] %s %s - stream span ended (session: %s)', method, url.pathname, sessionId);
590
+ // Note: We don't call waitUntilAll() here because this waitUntil callback
591
+ // IS the final cleanup task. Calling waitUntilAll() would deadlock since
592
+ // it would wait for this very promise to complete.
593
+ }
594
+ });
595
+ } else {
596
+ // Non-streaming: record duration immediately
597
+ const durationNs = Math.round(handlerDurationMs * 1_000_000);
598
+ internal.info('[request] %s %s - recording duration: %sms (%dns) (session: %s)',
599
+ method, url.pathname, handlerDurationMs.toFixed(2), durationNs, sessionId);
600
+ span.setAttribute('@agentuity/request.duration', durationNs);
601
+ span.setAttribute('http.status_code', responseStatus);
602
+
603
+ // Capture pending promises BEFORE adding finalization waitUntil to avoid deadlock.
604
+ // If we called waitUntilAll inside waitUntil, it would wait for itself.
605
+ const pendingPromises = handler.getPendingSnapshot();
606
+ const hasPendingTasks = pendingPromises.length > 0;
607
+
608
+ if (hasPendingTasks) {
609
+ internal.info('[request] %s %s - %d pending waitUntil tasks to wait for (session: %s)',
610
+ method, url.pathname, pendingPromises.length, sessionId);
611
+ }
612
+
613
+ // Capture values for use in waitUntil callback
614
+ const capturedResponseStatus = responseStatus;
615
+ const capturedErrorMessage = errorMessage;
616
+
617
+ // Defer session finalization to run AFTER response is sent
618
+ // Use noSpan: true since finalizeSession creates its own Session End span
619
+ handler.waitUntil(async () => {
620
+ // Wait for the snapshot of pending tasks (evals, etc.) captured BEFORE this waitUntil was added
621
+ if (hasPendingTasks) {
622
+ internal.info('[request] %s %s - waiting for %d pending waitUntil tasks (session: %s)',
623
+ method, url.pathname, pendingPromises.length, sessionId);
624
+ const logger = c.get('logger');
625
+ await handler.waitForPromises(pendingPromises, logger, sessionId);
626
+ internal.info('[request] %s %s - all waitUntil tasks complete (session: %s)', method, url.pathname, sessionId);
627
+ }
628
+
629
+ // Finalize session - this is the actual work
630
+ internal.info('[request] %s %s - starting session finalization (session: %s)', method, url.pathname, sessionId);
631
+ try {
632
+ await finalizeSession(capturedResponseStatus >= 500 ? capturedResponseStatus : undefined, capturedErrorMessage);
633
+ internal.info('[request] %s %s - session finalization complete (session: %s)', method, url.pathname, sessionId);
634
+ } catch (ex) {
635
+ internal.error('[request] %s %s - session finalization failed: %s (session: %s)',
636
+ method, url.pathname, ex, sessionId);
637
+ }
638
+ // Note: We don't call waitUntilAll() here because this waitUntil callback
639
+ // IS the final cleanup task. Calling waitUntilAll() would deadlock since
640
+ // it would wait for this very promise to complete.
641
+ }, { noSpan: true });
642
+ }
483
643
  } catch (ex) {
644
+ // Record request metrics even on exceptions (500 status)
645
+ const exceptionDurationMs = performance.now() - requestStartTime;
646
+ const durationNs = Math.round(exceptionDurationMs * 1_000_000);
647
+ internal.info('[request] %s %s - recording exception duration: %sms (session: %s)',
648
+ method, url.pathname, exceptionDurationMs.toFixed(2), sessionId);
649
+ span.setAttribute('@agentuity/request.duration', durationNs);
650
+ span.setAttribute('http.status_code', 500);
651
+
484
652
  if (ex instanceof Error) {
485
653
  span.recordException(ex);
486
654
  }
487
- const errorMessage = ex instanceof Error ? (ex.stack ?? ex.message) : String(ex);
655
+ errorMessage = ex instanceof Error ? (ex.stack ?? ex.message) : String(ex);
656
+ responseStatus = 500;
488
657
  span.setStatus({
489
658
  code: SpanStatusCode.ERROR,
490
659
  message: ex instanceof Error ? ex.message : String(ex),
491
660
  });
492
661
 
493
- await finalizeSession(500, errorMessage);
662
+ // Capture error message for use in waitUntil callback
663
+ const capturedErrorMessage = errorMessage;
664
+
665
+ // Capture pending promises BEFORE adding finalization waitUntil to avoid deadlock
666
+ const pendingPromises = handler.getPendingSnapshot();
667
+ const hasPendingTasks = pendingPromises.length > 0;
668
+
669
+ if (hasPendingTasks) {
670
+ internal.info('[request] %s %s - %d pending waitUntil tasks to wait for after error (session: %s)',
671
+ method, url.pathname, pendingPromises.length, sessionId);
672
+ }
673
+
674
+ // Still defer finalization even on error
675
+ // Use noSpan: true since finalizeSession creates its own Session End span
676
+ handler.waitUntil(async () => {
677
+ // Wait for pending tasks (evals, etc.) captured BEFORE this waitUntil was added
678
+ if (hasPendingTasks) {
679
+ internal.info('[request] %s %s - waiting for %d pending waitUntil tasks (session: %s)',
680
+ method, url.pathname, pendingPromises.length, sessionId);
681
+ const logger = c.get('logger');
682
+ await handler.waitForPromises(pendingPromises, logger, sessionId);
683
+ internal.info('[request] %s %s - all waitUntil tasks complete (session: %s)', method, url.pathname, sessionId);
684
+ }
685
+
686
+ try {
687
+ await finalizeSession(500, capturedErrorMessage);
688
+ } catch (finalizeEx) {
689
+ internal.error('[request] %s %s - error session finalization failed: %s (session: %s)',
690
+ method, url.pathname, finalizeEx, sessionId);
691
+ }
692
+ // Note: We don't call waitUntilAll() here because this waitUntil callback
693
+ // IS the final cleanup task. Calling waitUntilAll() would deadlock since
694
+ // it would wait for this very promise to complete.
695
+ }, { noSpan: true });
494
696
 
495
697
  throw ex;
496
698
  } finally {
699
+ // Set response headers - this is the only thing that should block the response
497
700
  const headers: Record<string, string> = {};
498
701
  propagation.inject(context.active(), headers);
499
702
  for (const key of Object.keys(headers)) {
@@ -501,7 +704,15 @@ export function createOtelMiddleware() {
501
704
  }
502
705
  const traceId = sctx?.traceId || sessionId.replace(/^sess_/, '');
503
706
  c.header(SESSION_HEADER, `sess_${traceId}`);
504
- span.end();
707
+
708
+ internal.info('[request] %s %s - response ready, duration: %sms (session: %s)',
709
+ method, url.pathname, handlerDurationMs.toFixed(2), sessionId);
710
+
711
+ // Only end span here for non-streaming responses
712
+ // For streaming, span is ended in the waitUntil callback after setting duration attributes
713
+ if (shouldEndSpanInFinally) {
714
+ span.end();
715
+ }
505
716
  }
506
717
  }
507
718
  );
package/src/router.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  import { type Context, Hono, type Schema, type Env as HonoEnv } from 'hono';
3
3
  import { returnResponse } from './_util';
4
4
  import type { Env } from './app';
5
+ import { loadBuildMetadata } from './_metadata';
5
6
 
6
7
  // Re-export both Env types
7
8
  export type { Env };
@@ -163,8 +164,34 @@ export const createRouter = <E extends Env = Env, S extends Schema = Schema>():
163
164
  return _originalInvoker(path, ...args);
164
165
  }
165
166
 
166
- // Wrap the handler to add our response conversion
167
+ // Wrap the handler to add our response conversion and set routeId
167
168
  const wrapper = async (c: Context): Promise<Response> => {
169
+ // Look up the route ID from build metadata by matching method and path
170
+ // Try both the registered path and the actual request path (which may include base path)
171
+ const metadata = loadBuildMetadata();
172
+ const methodUpper = method.toUpperCase();
173
+ const requestPath = c.req.routePath || c.req.path;
174
+
175
+ // Try matching by registered path first, then by request path, then by path ending
176
+ let route = metadata?.routes?.find(
177
+ (r) => r.method.toUpperCase() === methodUpper && r.path === path
178
+ );
179
+ if (!route) {
180
+ route = metadata?.routes?.find(
181
+ (r) => r.method.toUpperCase() === methodUpper && r.path === requestPath
182
+ );
183
+ }
184
+ if (!route) {
185
+ // Try matching by path ending (handles /api/translate matching /translate)
186
+ route = metadata?.routes?.find(
187
+ (r) => r.method.toUpperCase() === methodUpper && r.path.endsWith(path)
188
+ );
189
+ }
190
+
191
+ if (route?.id) {
192
+ (c as any).set('routeId', route.id);
193
+ }
194
+
168
195
  let result = handler(c);
169
196
  if (result instanceof Promise) result = await result;
170
197
  // If handler returns a Response, return it unchanged
package/src/session.ts CHANGED
@@ -1408,6 +1408,7 @@ export class ThreadWebSocketClient {
1408
1408
  this.ws = new WebSocket(this.wsUrl);
1409
1409
 
1410
1410
  this.ws.addEventListener('open', () => {
1411
+ internal.info('WebSocket connected');
1411
1412
  // Send authentication (do NOT clear timeout yet - wait for auth response)
1412
1413
  this.ws?.send(JSON.stringify({ authorization: this.apiKey }));
1413
1414
  });