@output.ai/core 0.4.3 → 0.4.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@output.ai/core",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "description": "The core module of the output framework",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,29 @@
1
+ import * as z from 'zod';
2
+
3
+ const envVarSchema = z.object( {
4
+ OUTPUT_AWS_REGION: z.string(),
5
+ OUTPUT_AWS_ACCESS_KEY_ID: z.string(),
6
+ OUTPUT_AWS_SECRET_ACCESS_KEY: z.string(),
7
+ OUTPUT_TRACE_REMOTE_S3_BUCKET: z.string(),
8
+ OUTPUT_REDIS_URL: z.string(),
9
+ OUTPUT_REDIS_TRACE_TTL: z.coerce.number().int().positive().default( 60 * 60 * 24 * 7 ) // 7 days
10
+ } );
11
+
12
+ const env = {};
13
+
14
+ export const loadEnv = () => {
15
+ const parsedFields = envVarSchema.parse( process.env );
16
+ env.awsRegion = parsedFields.OUTPUT_AWS_REGION;
17
+ env.awsAccessKeyId = parsedFields.OUTPUT_AWS_ACCESS_KEY_ID;
18
+ env.awsSecretAccessKey = parsedFields.OUTPUT_AWS_SECRET_ACCESS_KEY;
19
+ env.remoteS3Bucket = parsedFields.OUTPUT_TRACE_REMOTE_S3_BUCKET;
20
+ env.redisUrl = parsedFields.OUTPUT_REDIS_URL;
21
+ env.redisIncompleteWorkflowsTTL = parsedFields.OUTPUT_REDIS_TRACE_TTL;
22
+ };
23
+
24
+ export const getVars = () => {
25
+ if ( Object.keys( env ).length === 0 ) {
26
+ throw new Error( 'Env vars not loaded. Use loadEnv() first.' );
27
+ }
28
+ return env;
29
+ };
@@ -0,0 +1,64 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+
3
+ async function loadModule() {
4
+ vi.resetModules();
5
+ return import( './configs.js' );
6
+ }
7
+
8
+ describe( 'tracing/processors/s3/configs', () => {
9
+ const required = {
10
+ OUTPUT_AWS_REGION: 'us-east-1',
11
+ OUTPUT_AWS_ACCESS_KEY_ID: 'id',
12
+ OUTPUT_AWS_SECRET_ACCESS_KEY: 'sek',
13
+ OUTPUT_TRACE_REMOTE_S3_BUCKET: 'bkt',
14
+ OUTPUT_REDIS_URL: 'redis://localhost:6379'
15
+ };
16
+
17
+ beforeEach( () => {
18
+ vi.stubEnv( 'OUTPUT_AWS_REGION', required.OUTPUT_AWS_REGION );
19
+ vi.stubEnv( 'OUTPUT_AWS_ACCESS_KEY_ID', required.OUTPUT_AWS_ACCESS_KEY_ID );
20
+ vi.stubEnv( 'OUTPUT_AWS_SECRET_ACCESS_KEY', required.OUTPUT_AWS_SECRET_ACCESS_KEY );
21
+ vi.stubEnv( 'OUTPUT_TRACE_REMOTE_S3_BUCKET', required.OUTPUT_TRACE_REMOTE_S3_BUCKET );
22
+ vi.stubEnv( 'OUTPUT_REDIS_URL', required.OUTPUT_REDIS_URL );
23
+ } );
24
+
25
+ afterEach( () => {
26
+ vi.unstubAllEnvs();
27
+ } );
28
+
29
+ it( 'loadEnv() throws when required env vars are missing', async () => {
30
+ vi.stubEnv( 'OUTPUT_REDIS_URL', undefined );
31
+ const { loadEnv } = await loadModule();
32
+ expect( () => loadEnv() ).toThrow( /OUTPUT_REDIS_URL/ );
33
+ } );
34
+
35
+ it( 'loadEnv() populates getVars() with parsed env', async () => {
36
+ const { loadEnv, getVars } = await loadModule();
37
+ loadEnv();
38
+ const vars = getVars();
39
+ expect( vars.awsRegion ).toBe( required.OUTPUT_AWS_REGION );
40
+ expect( vars.awsAccessKeyId ).toBe( required.OUTPUT_AWS_ACCESS_KEY_ID );
41
+ expect( vars.awsSecretAccessKey ).toBe( required.OUTPUT_AWS_SECRET_ACCESS_KEY );
42
+ expect( vars.remoteS3Bucket ).toBe( required.OUTPUT_TRACE_REMOTE_S3_BUCKET );
43
+ expect( vars.redisUrl ).toBe( required.OUTPUT_REDIS_URL );
44
+ expect( vars.redisIncompleteWorkflowsTTL ).toBe( 60 * 60 * 24 * 7 );
45
+ } );
46
+
47
+ it( 'loadEnv() uses OUTPUT_REDIS_TRACE_TTL when set', async () => {
48
+ vi.stubEnv( 'OUTPUT_REDIS_TRACE_TTL', '3600' );
49
+ const { loadEnv, getVars } = await loadModule();
50
+ loadEnv();
51
+ expect( getVars().redisIncompleteWorkflowsTTL ).toBe( 3600 );
52
+ } );
53
+
54
+ it( 'getVars() throws when loadEnv() was not called', async () => {
55
+ const { getVars } = await loadModule();
56
+ expect( () => getVars() ).toThrow( 'Env vars not loaded. Use loadEnv() first.' );
57
+ } );
58
+
59
+ it( 'loadEnv() throws when OUTPUT_REDIS_TRACE_TTL is invalid', async () => {
60
+ vi.stubEnv( 'OUTPUT_REDIS_TRACE_TTL', 'not-a-number' );
61
+ const { loadEnv } = await loadModule();
62
+ expect( () => loadEnv() ).toThrow();
63
+ } );
64
+ } );
@@ -2,25 +2,51 @@ import { upload } from './s3_client.js';
2
2
  import { getRedisClient } from './redis_client.js';
3
3
  import buildTraceTree from '../../tools/build_trace_tree.js';
4
4
  import { EOL } from 'node:os';
5
+ import { loadEnv, getVars } from './configs.js';
5
6
 
6
- const oneMonthInSeconds = 60 * 60 * 24 * 30;
7
+ const createRedisKey = ( { workflowId, workflowName } ) => `traces/${workflowName}/${workflowId}`;
7
8
 
8
- const addEntry = async ( { entry, executionContext: { workflowName, workflowId } } ) => {
9
- const key = `traces/${workflowName}/${workflowId}`;
9
+ /**
10
+ * Add new entry to list of entries
11
+ * @param {object} entry
12
+ * @param {string} key
13
+ */
14
+ const addEntry = async ( entry, key ) => {
10
15
  const client = await getRedisClient();
11
16
  await client.multi()
12
17
  .zAdd( key, [ { score: entry.timestamp, value: JSON.stringify( entry ) } ], { NX: true } )
13
- .expire( key, oneMonthInSeconds, 'GT' )
18
+ .expire( key, getVars().redisIncompleteWorkflowsTTL, 'GT' )
14
19
  .exec();
15
20
  };
16
21
 
17
- const getAllEntries = async ( { workflowName, workflowId } ) => {
18
- const key = `traces/${workflowName}/${workflowId}`;
22
+ /**
23
+ * Returns entries from cache, parsed to object
24
+ * @param {string} key
25
+ * @returns {object[]}
26
+ */
27
+ const getEntries = async key => {
19
28
  const client = await getRedisClient();
20
29
  const zList = await client.zRange( key, 0, -1 );
21
30
  return zList.map( v => JSON.parse( v ) );
22
31
  };
23
32
 
33
+ /**
34
+ * Removes the entries from cache
35
+ * @param {string} key
36
+ */
37
+ const bustEntries = async key => {
38
+ const client = await getRedisClient();
39
+ await client.del( key );
40
+ };
41
+
42
+ /**
43
+ * Return the S3 key for the trace file
44
+ * @param {object} args
45
+ * @param {number} args.startTime
46
+ * @param {string} args.workflowId
47
+ * @param {string} args.workflowName
48
+ * @returns
49
+ */
24
50
  const getS3Key = ( { startTime, workflowId, workflowName } ) => {
25
51
  const isoDate = new Date( startTime ).toISOString();
26
52
  const [ year, month, day ] = isoDate.split( /\D/, 3 );
@@ -32,6 +58,7 @@ const getS3Key = ( { startTime, workflowId, workflowName } ) => {
32
58
  * Init this processor
33
59
  */
34
60
  export const init = async () => {
61
+ loadEnv();
35
62
  await getRedisClient();
36
63
  };
37
64
 
@@ -44,19 +71,21 @@ export const init = async () => {
44
71
  */
45
72
  export const exec = async ( { entry, executionContext } ) => {
46
73
  const { workflowName, workflowId, startTime } = executionContext;
74
+ const cacheKey = createRedisKey( { workflowId, workflowName } );
47
75
 
48
- await addEntry( { entry, executionContext } );
76
+ await addEntry( entry, cacheKey );
49
77
 
50
78
  const isRootWorkflowEnd = !entry.parentId && entry.phase !== 'start';
51
79
  if ( !isRootWorkflowEnd ) {
52
- return 0;
80
+ return;
53
81
  }
54
82
 
55
- const content = buildTraceTree( await getAllEntries( { workflowName, workflowId } ) );
56
- return upload( {
83
+ const content = buildTraceTree( await getEntries( cacheKey ) );
84
+ await upload( {
57
85
  key: getS3Key( { workflowId, workflowName, startTime } ),
58
86
  content: JSON.stringify( content, undefined, 2 ) + EOL
59
87
  } );
88
+ await bustEntries( cacheKey );
60
89
  };
61
90
 
62
91
  /**
@@ -68,4 +97,4 @@ export const exec = async ( { entry, executionContext } ) => {
68
97
  * @returns {string} The S3 url of the trace file
69
98
  */
70
99
  export const getDestination = ( { startTime, workflowId, workflowName } ) =>
71
- `https://${process.env.OUTPUT_TRACE_REMOTE_S3_BUCKET}.s3.amazonaws.com/${getS3Key( { workflowId, workflowName, startTime } )}`;
100
+ `https://${getVars().remoteS3Bucket}.s3.amazonaws.com/${getS3Key( { workflowId, workflowName, startTime } )}`;
@@ -1,12 +1,24 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
 
3
+ const loadEnvMock = vi.fn();
4
+ const getVarsMock = vi.fn( () => ( {
5
+ remoteS3Bucket: 'bkt',
6
+ redisIncompleteWorkflowsTTL: 3600
7
+ } ) );
8
+ vi.mock( './configs.js', () => ( { loadEnv: loadEnvMock, getVars: getVarsMock } ) );
9
+
3
10
  const redisMulti = {
4
11
  zAdd: vi.fn().mockReturnThis(),
5
12
  expire: vi.fn().mockReturnThis(),
6
13
  exec: vi.fn()
7
14
  };
8
15
  const zRangeMock = vi.fn();
9
- const getRedisClientMock = vi.fn( async () => ( { multi: () => redisMulti, zRange: zRangeMock } ) );
16
+ const delMock = vi.fn().mockResolvedValue( undefined );
17
+ const getRedisClientMock = vi.fn( async () => ( {
18
+ multi: () => redisMulti,
19
+ zRange: zRangeMock,
20
+ del: delMock
21
+ } ) );
10
22
  vi.mock( './redis_client.js', () => ( { getRedisClient: getRedisClientMock } ) );
11
23
 
12
24
  const uploadMock = vi.fn();
@@ -18,12 +30,13 @@ vi.mock( '../../tools/build_trace_tree.js', () => ( { default: buildTraceTreeMoc
18
30
  describe( 'tracing/processors/s3', () => {
19
31
  beforeEach( () => {
20
32
  vi.clearAllMocks();
21
- process.env.OUTPUT_TRACE_REMOTE_S3_BUCKET = 'bkt';
33
+ getVarsMock.mockReturnValue( { remoteS3Bucket: 'bkt', redisIncompleteWorkflowsTTL: 3600 } );
22
34
  } );
23
35
 
24
- it( 'init(): ensures redis client is created', async () => {
36
+ it( 'init(): loads config and ensures redis client is created', async () => {
25
37
  const { init } = await import( './index.js' );
26
38
  await init();
39
+ expect( loadEnvMock ).toHaveBeenCalledTimes( 1 );
27
40
  expect( getRedisClientMock ).toHaveBeenCalledTimes( 1 );
28
41
  } );
29
42
 
@@ -59,6 +72,20 @@ describe( 'tracing/processors/s3', () => {
59
72
  const { key, content } = uploadMock.mock.calls[0][0];
60
73
  expect( key ).toMatch( /^WF\/2020\/01\/02\// );
61
74
  expect( JSON.parse( content.trim() ).count ).toBe( 3 );
75
+
76
+ expect( delMock ).toHaveBeenCalledTimes( 1 );
77
+ expect( delMock ).toHaveBeenCalledWith( 'traces/WF/id1' );
78
+ } );
79
+
80
+ it( 'getDestination(): returns S3 URL using bucket and key from getVars', async () => {
81
+ getVarsMock.mockReturnValue( { remoteS3Bucket: 'my-bucket', redisIncompleteWorkflowsTTL: 3600 } );
82
+ const { getDestination } = await import( './index.js' );
83
+ const startTime = Date.parse( '2020-01-02T03:04:05.678Z' );
84
+ const url = getDestination( { workflowId: 'id1', workflowName: 'WF', startTime } );
85
+ expect( getVarsMock ).toHaveBeenCalled();
86
+ expect( url ).toBe(
87
+ 'https://my-bucket.s3.amazonaws.com/WF/2020/01/02/2020-01-02-03-04-05-678Z_id1.json'
88
+ );
62
89
  } );
63
90
  } );
64
91
 
@@ -1,6 +1,6 @@
1
1
  import { createClient } from 'redis';
2
- import { throws } from '#utils';
3
2
  import { createChildLogger } from '#logger';
3
+ import { getVars } from './configs.js';
4
4
 
5
5
  const log = createChildLogger( 'RedisClient' );
6
6
 
@@ -36,11 +36,10 @@ async function connect( url ) {
36
36
  * Concurrent calls during connection will receive the same pending promise.
37
37
  *
38
38
  * @returns {Promise<redis.RedisClientType>} Connected Redis client
39
- * @throws {Error} If OUTPUT_REDIS_URL env var is missing
40
39
  * @throws {Error} If connection fails (wrapped with context)
41
40
  */
42
41
  export async function getRedisClient() {
43
- const url = process.env.OUTPUT_REDIS_URL ?? throws( new Error( 'Missing OUTPUT_REDIS_URL environment variable' ) );
42
+ const url = getVars().redisUrl;
44
43
 
45
44
  const pingResult = await state.client?.ping().catch( err => {
46
45
  log.error( 'Redis ping failed', { error: err.message, code: err.code } );
@@ -14,6 +14,9 @@ vi.mock( '#logger', () => ( {
14
14
  } )
15
15
  } ) );
16
16
 
17
+ const getVarsMock = vi.fn();
18
+ vi.mock( './configs.js', () => ( { getVars: () => getVarsMock() } ) );
19
+
17
20
  const createClientImpl = vi.fn();
18
21
  vi.mock( 'redis', () => ( { createClient: opts => createClientImpl( opts ) } ) );
19
22
 
@@ -25,7 +28,7 @@ async function loadModule() {
25
28
  describe( 'tracing/processors/s3/redis_client', () => {
26
29
  beforeEach( () => {
27
30
  vi.clearAllMocks();
28
- delete process.env.OUTPUT_REDIS_URL;
31
+ getVarsMock.mockReturnValue( {} );
29
32
  logCalls.warn = [];
30
33
  logCalls.error = [];
31
34
  } );
@@ -34,13 +37,14 @@ describe( 'tracing/processors/s3/redis_client', () => {
34
37
  vi.useRealTimers();
35
38
  } );
36
39
 
37
- it( 'throws if OUTPUT_REDIS_URL is missing', async () => {
40
+ it( 'throws when config redisUrl is missing', async () => {
41
+ getVarsMock.mockReturnValue( {} );
38
42
  const { getRedisClient } = await loadModule();
39
- await expect( getRedisClient() ).rejects.toThrow( 'Missing OUTPUT_REDIS_URL' );
43
+ await expect( getRedisClient() ).rejects.toThrow();
40
44
  } );
41
45
 
42
46
  it( 'creates client with url, connects once, then reuses cached when ping is PONG', async () => {
43
- process.env.OUTPUT_REDIS_URL = 'redis://localhost:6379';
47
+ getVarsMock.mockReturnValue( { redisUrl: 'redis://localhost:6379' } );
44
48
 
45
49
  const pingMock = vi.fn().mockResolvedValue( 'PONG' );
46
50
  const connectMock = vi.fn().mockResolvedValue();
@@ -63,7 +67,7 @@ describe( 'tracing/processors/s3/redis_client', () => {
63
67
  } );
64
68
 
65
69
  it( 'closes stale client and reconnects when ping fails', async () => {
66
- process.env.OUTPUT_REDIS_URL = 'redis://localhost:6379';
70
+ getVarsMock.mockReturnValue( { redisUrl: 'redis://localhost:6379' } );
67
71
 
68
72
  const quitMock = vi.fn().mockResolvedValue();
69
73
  const connectMock = vi.fn().mockResolvedValue();
@@ -92,7 +96,7 @@ describe( 'tracing/processors/s3/redis_client', () => {
92
96
  } );
93
97
 
94
98
  it( 'reconnects successfully even when quit() on stale client rejects', async () => {
95
- process.env.OUTPUT_REDIS_URL = 'redis://localhost:6379';
99
+ getVarsMock.mockReturnValue( { redisUrl: 'redis://localhost:6379' } );
96
100
 
97
101
  const quitMock = vi.fn().mockRejectedValue( new Error( 'Quit failed' ) );
98
102
  const connectMock = vi.fn().mockResolvedValue();
@@ -121,7 +125,7 @@ describe( 'tracing/processors/s3/redis_client', () => {
121
125
  } );
122
126
 
123
127
  it( 'wraps connect() errors with code and cleans up failed client', async () => {
124
- process.env.OUTPUT_REDIS_URL = 'redis://localhost:6379';
128
+ getVarsMock.mockReturnValue( { redisUrl: 'redis://localhost:6379' } );
125
129
 
126
130
  const connectErr = new Error( 'Connection refused' );
127
131
  connectErr.code = 'ECONNREFUSED';
@@ -147,7 +151,7 @@ describe( 'tracing/processors/s3/redis_client', () => {
147
151
  } );
148
152
 
149
153
  it( 'logs ping failures with error level', async () => {
150
- process.env.OUTPUT_REDIS_URL = 'redis://localhost:6379';
154
+ getVarsMock.mockReturnValue( { redisUrl: 'redis://localhost:6379' } );
151
155
 
152
156
  const pingErr = new Error( 'Connection reset' );
153
157
  pingErr.code = 'ECONNRESET';
@@ -1,5 +1,5 @@
1
1
  import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
2
- import { throws } from '#utils';
2
+ import { getVars } from './configs.js';
3
3
 
4
4
  const state = { s3Client: null };
5
5
 
@@ -12,9 +12,7 @@ const getS3Client = () => {
12
12
  return state.s3Client;
13
13
  }
14
14
 
15
- const region = process.env.OUTPUT_AWS_REGION ?? throws( new Error( 'Missing OUTPUT_AWS_REGION env var' ) );
16
- const secretAccessKey = process.env.OUTPUT_AWS_SECRET_ACCESS_KEY ?? throws( new Error( 'Missing OUTPUT_AWS_SECRET_ACCESS_KEY env var' ) );
17
- const accessKeyId = process.env.OUTPUT_AWS_ACCESS_KEY_ID ?? throws( new Error( 'Missing OUTPUT_AWS_ACCESS_KEY_ID env var' ) );
15
+ const { awsRegion: region, awsSecretAccessKey: secretAccessKey, awsAccessKeyId: accessKeyId } = getVars();
18
16
 
19
17
  return state.s3Client = new S3Client( { region, credentials: { accessKeyId, secretAccessKey } } );
20
18
  };
@@ -26,8 +24,4 @@ const getS3Client = () => {
26
24
  * @param {string} content - File content
27
25
  */
28
26
  export const upload = ( { key, content } ) =>
29
- getS3Client().send( new PutObjectCommand( {
30
- Bucket: process.env.OUTPUT_TRACE_REMOTE_S3_BUCKET ?? throws( new Error( 'Missing OUTPUT_TRACE_REMOTE_S3_BUCKET env var' ) ),
31
- Key: key,
32
- Body: content
33
- } ) );
27
+ getS3Client().send( new PutObjectCommand( { Bucket: getVars().remoteS3Bucket, Key: key, Body: content } ) );
@@ -6,6 +6,9 @@ vi.mock( '#utils', () => ( {
6
6
  }
7
7
  } ) );
8
8
 
9
+ const getVarsMock = vi.fn();
10
+ vi.mock( './configs', () => ( { getVars: () => getVarsMock() } ) );
11
+
9
12
  const sendMock = vi.fn();
10
13
  const ctorState = { args: null };
11
14
  class S3ClientMock {
@@ -32,23 +35,15 @@ async function loadModule() {
32
35
  describe( 'tracing/processors/s3/s3_client', () => {
33
36
  beforeEach( () => {
34
37
  vi.clearAllMocks();
35
- delete process.env.OUTPUT_AWS_REGION;
36
- delete process.env.OUTPUT_AWS_SECRET_ACCESS_KEY;
37
- delete process.env.OUTPUT_AWS_ACCESS_KEY_ID;
38
- delete process.env.OUTPUT_TRACE_REMOTE_S3_BUCKET;
38
+ getVarsMock.mockReturnValue( {
39
+ awsRegion: 'us-east-1',
40
+ awsAccessKeyId: 'id',
41
+ awsSecretAccessKey: 'sek',
42
+ remoteS3Bucket: 'bucket'
43
+ } );
39
44
  } );
40
45
 
41
- it( 'fails fast when required env vars are missing for client creation', async () => {
42
- const { upload } = await loadModule();
43
- expect( () => upload( { key: 'k', content: 'c' } ) ).toThrow();
44
- } );
45
-
46
- it( 'creates client once with env and uploads with bucket/key/content', async () => {
47
- process.env.OUTPUT_AWS_REGION = 'us-east-1';
48
- process.env.OUTPUT_AWS_SECRET_ACCESS_KEY = 'sek';
49
- process.env.OUTPUT_AWS_ACCESS_KEY_ID = 'id';
50
- process.env.OUTPUT_TRACE_REMOTE_S3_BUCKET = 'bucket';
51
-
46
+ it( 'creates client once with config and uploads with bucket/key/content', async () => {
52
47
  const { upload } = await loadModule();
53
48
 
54
49
  await upload( { key: 'wf/key.json', content: '{"a":1}' } );