@ossy/platform 1.31.2 → 1.32.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/README.md ADDED
@@ -0,0 +1,95 @@
1
+ # @ossy/platform
2
+
3
+ Ossy application server runtime — Express-based server that loads the build manifest produced by `@ossy/app` and serves pages, APIs, tasks, and integrations.
4
+
5
+ ---
6
+
7
+ ## `*.integration.js` — third-party integration primitive
8
+
9
+ An integration file declares a named third-party client (e.g. a database, email provider, or payment SDK) that the platform connects to once at startup and makes available to every task.
10
+
11
+ ### File naming
12
+
13
+ Name the file `<slug>.integration.js` (or `.mjs` / `.cjs`) and place it anywhere inside `src/`. The build pipeline discovers it automatically using the `*.integration.js` pattern, just like `*.task.js` and `*.page.jsx`.
14
+
15
+ ### Required exports
16
+
17
+ | Export | Type | Description |
18
+ |---|---|---|
19
+ | `id` | `string` | Unique slug used to retrieve the client — e.g. `'email'`, `'stripe'`. |
20
+ | `credentials` | `string[]` | Names of environment variables that must be present. If any are missing the integration is skipped at startup with a warning. |
21
+ | `connect` | `async ({ env }) => client` | Called once at startup with the full `process.env`. Returns the client object stored by `id`. If it throws, the integration is skipped (non-fatal). |
22
+
23
+ ### Minimal example
24
+
25
+ ```js
26
+ // src/stripe.integration.js
27
+ import Stripe from 'stripe'
28
+
29
+ export const id = 'stripe'
30
+
31
+ export const credentials = ['STRIPE_SECRET_KEY']
32
+
33
+ export async function connect ({ env }) {
34
+ return new Stripe(env.STRIPE_SECRET_KEY)
35
+ }
36
+ ```
37
+
38
+ ### Consuming integrations inside a task
39
+
40
+ The platform injects `integrations` into every task's `run()` call alongside `sdk`:
41
+
42
+ ```js
43
+ // src/send-invoice.task.js
44
+ export const metadata = {
45
+ id: 'send-invoice',
46
+ triggers: [{ aggregateType: 'Invoice', event: 'InvoiceCreated' }],
47
+ }
48
+
49
+ export async function run ({ event, sdk, integrations }) {
50
+ const stripe = integrations.get('stripe') // null if credentials are missing
51
+ if (!stripe) return
52
+
53
+ await stripe.invoices.send(event.payload.stripeInvoiceId)
54
+ }
55
+ ```
56
+
57
+ `integrations.get(id)` returns `null` when the integration was not loaded (missing credentials, failed `connect()`, or not declared anywhere).
58
+
59
+ ### Manifest output
60
+
61
+ After `app build` the manifest contains an `integrations` array:
62
+
63
+ ```json
64
+ {
65
+ "integrations": [
66
+ {
67
+ "id": "stripe",
68
+ "entry": "/static/stripe.integration-7f2a.js",
69
+ "credentials": ["STRIPE_SECRET_KEY"]
70
+ }
71
+ ]
72
+ }
73
+ ```
74
+
75
+ ### Built-in integrations
76
+
77
+ `@ossy/platform` ships with the following integration that is auto-discovered for every app using the package:
78
+
79
+ | id | Credentials | Client |
80
+ |---|---|---|
81
+ | `email` | `SES_REGION`, `SES_ACCESS_KEY_ID`, `SES_SECRET_ACCESS_KEY` | `SESClient` from `@aws-sdk/client-ses` |
82
+
83
+ If the SES env vars are not set the `email` integration is silently skipped and `integrations.get('email')` returns `null`.
84
+
85
+ ### `IntegrationService` API
86
+
87
+ ```js
88
+ import { IntegrationService } from '@ossy/platform/integrations'
89
+
90
+ // Load a list of already-imported integration modules (done automatically by the server).
91
+ await IntegrationService.load(modules, process.env)
92
+
93
+ // Retrieve a connected client by id.
94
+ const client = IntegrationService.get('email') // SESClient | null
95
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ossy/platform",
3
- "version": "1.31.2",
3
+ "version": "1.32.0",
4
4
  "description": "Ossy application server runtime",
5
5
  "repository": {
6
6
  "type": "git",
@@ -19,7 +19,8 @@
19
19
  "./site-loader": "./src/site-loader.js",
20
20
  "./tasks": "./src/index.js",
21
21
  "./resources": "./src/resources/index.js",
22
- "./definition": "./src/Definition.js"
22
+ "./definition": "./src/Definition.js",
23
+ "./integrations": "./src/integration.service.js"
23
24
  },
24
25
  "scripts": {
25
26
  "start": "PORT=3003 node -e \"import('./src/server.js').then(m => m.startServer())\""
@@ -28,8 +29,11 @@
28
29
  "author": "Ossy <yourfriends@ossy.se> (https://ossy.se)",
29
30
  "license": "MIT",
30
31
  "dependencies": {
31
- "@ossy/router": "^1.32.2",
32
- "@ossy/sdk": "^1.32.2",
32
+ "@aws-sdk/client-ses": "^3.0.0",
33
+ "@ossy/event-store": "^1.1.0",
34
+ "@ossy/observability": "^1.1.0",
35
+ "@ossy/router": "^1.33.0",
36
+ "@ossy/sdk": "^1.33.0",
33
37
  "cookie-parser": "^1.4.7",
34
38
  "dotenv": ">=16.0.0 <18.0.0",
35
39
  "express": ">=5.0.0 <6.0.0",
@@ -40,5 +44,5 @@
40
44
  "src",
41
45
  "Dockerfile"
42
46
  ],
43
- "gitHead": "5c17e379298f7027f1dbdcf064a4aff69d1497d5"
47
+ "gitHead": "9a8a1bb0466d35001425d241d77c2c9ab31e11d3"
44
48
  }
@@ -0,0 +1,26 @@
1
+ import { SESClient } from '@aws-sdk/client-ses'
2
+
3
+ export const id = 'email'
4
+
5
+ export const credentials = [
6
+ 'SES_REGION',
7
+ 'SES_ACCESS_KEY_ID',
8
+ 'SES_SECRET_ACCESS_KEY',
9
+ ]
10
+
11
+ /**
12
+ * Returns an AWS SES client configured from env vars.
13
+ * Tasks receive this via `integrations.get('email')`.
14
+ *
15
+ * @param {{ env: NodeJS.ProcessEnv }} opts
16
+ * @returns {SESClient}
17
+ */
18
+ export async function connect ({ env }) {
19
+ return new SESClient({
20
+ region: env.SES_REGION,
21
+ credentials: {
22
+ accessKeyId: env.SES_ACCESS_KEY_ID,
23
+ secretAccessKey: env.SES_SECRET_ACCESS_KEY,
24
+ },
25
+ })
26
+ }
package/src/index.js CHANGED
@@ -4,3 +4,4 @@ export { ChangeStream } from './tasks/change-stream.js'
4
4
  export { registerResourceTemplate, getSystemResourceTemplates } from './resources/resource-template.registry.js'
5
5
  export { normalizeAndValidateDocumentContent, validateResourceTemplatesForImport, ALLOWED_FIELD_TYPES } from './resources/resource-template.validation.js'
6
6
  export { Definition } from './Definition.js'
7
+ export { IntegrationService } from './integration.service.js'
@@ -0,0 +1,63 @@
1
+ import { createLogger } from '@ossy/observability'
2
+
3
+ const log = createLogger('platform')
4
+
5
+ /** @type {Record<string, unknown>} */
6
+ const _clients = {}
7
+
8
+ /**
9
+ * Manages third-party integration clients declared via `*.integration.js` files.
10
+ *
11
+ * Integrations are loaded once at server startup. If a required env var is
12
+ * missing, or if `connect()` throws, the integration is skipped with a warning
13
+ * so the server can still boot without every optional credential being present.
14
+ */
15
+ export const IntegrationService = {
16
+ /**
17
+ * Connect all integration modules, storing each resulting client by id.
18
+ * Pass the array of already-imported integration modules (each exporting
19
+ * `id`, `credentials`, and `connect`).
20
+ *
21
+ * @param {Array<{ id: string, credentials: string[], connect: (opts: { env: NodeJS.ProcessEnv }) => Promise<unknown> | unknown }>} modules
22
+ * @param {NodeJS.ProcessEnv} env Usually `process.env`.
23
+ */
24
+ async load (modules, env) {
25
+ for (const mod of modules) {
26
+ const { id, credentials = [], connect } = mod
27
+
28
+ if (typeof id !== 'string' || id.trim() === '') {
29
+ log.warn('[IntegrationService] Skipping integration with missing or invalid "id"')
30
+ continue
31
+ }
32
+
33
+ const missing = credentials.filter((key) => !env[key])
34
+ if (missing.length > 0) {
35
+ log.warn(
36
+ `[IntegrationService] Skipping integration "${id}" — missing env var(s): ${missing.join(', ')}`,
37
+ )
38
+ continue
39
+ }
40
+
41
+ try {
42
+ _clients[id] = await connect({ env })
43
+ log.info(`[IntegrationService] Connected integration "${id}"`)
44
+ } catch (err) {
45
+ log.warn(
46
+ `[IntegrationService] Integration "${id}" connect() failed — skipping: ${err && err.message ? err.message : err}`,
47
+ )
48
+ }
49
+ }
50
+ },
51
+
52
+ /**
53
+ * Retrieve a connected client by integration id.
54
+ * Returns `null` when the integration was not loaded (missing credentials,
55
+ * connect failure, or not declared in the manifest).
56
+ *
57
+ * @param {string} id
58
+ * @returns {unknown | null}
59
+ */
60
+ get (id) {
61
+ return _clients[id] ?? null
62
+ },
63
+ }
@@ -1,3 +1,7 @@
1
+ import { createLogger } from '@ossy/observability'
2
+
3
+ const log = createLogger('platform')
4
+
1
5
  /** Node/Express can join duplicate workspace headers as `id, id` — API expects a single id. */
2
6
  function normalizeWorkspaceIdHeader (value) {
3
7
  if (!value || value === 'undefined') return undefined
@@ -39,14 +43,14 @@ export function ProxyInternal () {
39
43
  }
40
44
 
41
45
  if (req.originalUrl.startsWith('/@ossy/users/me/app-settings') && req.method === 'GET') {
42
- console.log('[@ossy/platform][proxy] GET /@ossy/users/me/app-settings')
46
+ log.info('[@ossy/platform][proxy] GET /@ossy/users/me/app-settings')
43
47
  const userSettings = JSON.parse(req.signedCookies?.['x-ossy-user-settings'] || '{}')
44
48
  res.status(200)
45
49
  res.json(userSettings)
46
50
  return
47
51
  }
48
52
 
49
- console.log(`[@ossy/platform][proxy] ${req.method} ${req.originalUrl}`)
53
+ log.info(`[@ossy/platform][proxy] ${req.method} ${req.originalUrl}`)
50
54
 
51
55
  const domain = process.env.OSSY_API_URL || 'https://api.ossy.se'
52
56
  const url = `${domain}${req.originalUrl?.replace('/@ossy', '/api/v0')}`
@@ -79,7 +83,7 @@ export function ProxyInternal () {
79
83
  try {
80
84
  data = trimmed === '' ? null : JSON.parse(trimmed)
81
85
  } catch (error) {
82
- console.log('[@ossy/platform][proxy][error]', error)
86
+ log.error('[@ossy/platform][proxy][error]', undefined, error)
83
87
  res.removeHeader('content-length')
84
88
  const st = response.status
85
89
  if (st === 401 || st === 403 || st === 404) {
@@ -103,7 +107,7 @@ export function ProxyInternal () {
103
107
  })
104
108
  })
105
109
  .catch((error) => {
106
- console.log('[@ossy/platform][proxy][error]', error)
110
+ log.error('[@ossy/platform][proxy][error]', undefined, error)
107
111
  const status = error.status
108
112
  res.status(status || 500)
109
113
  res.json({ message: error.message || 'Internal Server Error' })
@@ -1,3 +1,7 @@
1
+ import { createLogger } from '@ossy/observability'
2
+
3
+ const log = createLogger('platform')
4
+
1
5
  /** @type {object[]} */
2
6
  const systemTemplates = []
3
7
 
@@ -12,7 +16,7 @@ export function registerResourceTemplate(template) {
12
16
  if (typeof template.id !== 'string' || template.id.trim() === '') return
13
17
  if (systemTemplates.find(t => t.id === template.id)) return
14
18
  systemTemplates.push(template)
15
- console.log(`[INFO][ResourceTemplateRegistry] Registered system template: ${template.id}`)
19
+ log.info(`[ResourceTemplateRegistry] Registered system template: ${template.id}`)
16
20
  }
17
21
 
18
22
  /**
package/src/runtime.js CHANGED
@@ -7,6 +7,9 @@ import { Router as OssyRouter } from '@ossy/router'
7
7
  import { loadManifest, resolveEntryUrl } from './server.js'
8
8
  import { ProxyInternal } from './proxy-internal.js'
9
9
  import { loadSite, invalidateSite } from './site-loader.js'
10
+ import { createLogger } from '@ossy/observability'
11
+
12
+ const log = createLogger('platform')
10
13
 
11
14
  const DEFAULT_PORT = 3000
12
15
 
@@ -119,7 +122,7 @@ export async function startRuntime ({ port } = {}) {
119
122
  try {
120
123
  context = await getSiteContext(domain)
121
124
  } catch (err) {
122
- console.error(`[@ossy/platform] Failed to load site for ${domain}:`, err.message)
125
+ log.error(`[@ossy/platform] Failed to load site for ${domain}`, undefined, err)
123
126
  res.status(503).type('text').send(`Site not available: ${domain}`)
124
127
  return
125
128
  }
@@ -177,7 +180,7 @@ export async function startRuntime ({ port } = {}) {
177
180
  const html = await mod.render(props)
178
181
  res.status(200).type('html').send(html)
179
182
  } catch (err) {
180
- console.error(`[@ossy/platform] Request error for ${domain}${requestUrl}:`, err)
183
+ log.error(`[@ossy/platform] Request error for ${domain}${requestUrl}`, undefined, err)
181
184
  if (!res.headersSent) res.status(500).type('text').send('Internal Server Error')
182
185
  }
183
186
  })
@@ -188,8 +191,8 @@ export async function startRuntime ({ port } = {}) {
188
191
  httpServer.once('error', reject)
189
192
  })
190
193
 
191
- console.log(`[@ossy/platform] Runtime running on http://localhost:${resolvedPort}`)
192
- console.log('[@ossy/platform] Press Ctrl+C to stop.')
194
+ log.info(`[@ossy/platform] Runtime running on http://localhost:${resolvedPort}`)
195
+ log.info('[@ossy/platform] Press Ctrl+C to stop.')
193
196
 
194
197
  const closeServer = () => new Promise((resolve) => server.close(() => resolve()))
195
198
 
@@ -197,7 +200,7 @@ export async function startRuntime ({ port } = {}) {
197
200
  const handleShutdown = async (signal) => {
198
201
  if (shuttingDown) return
199
202
  shuttingDown = true
200
- console.log(`\n[@ossy/platform] Received ${signal}, shutting down…`)
203
+ log.info(`[@ossy/platform] Received ${signal}, shutting down…`)
201
204
  try { await closeServer() } finally { process.exit(0) }
202
205
  }
203
206
  process.on('SIGINT', () => handleShutdown('SIGINT'))
@@ -208,6 +211,6 @@ export async function startRuntime ({ port } = {}) {
208
211
 
209
212
  // Run directly: node src/runtime.js
210
213
  startRuntime().catch((err) => {
211
- console.error('[@ossy/platform] Runtime failed to start:', err)
214
+ log.error('[@ossy/platform] Runtime failed to start', undefined, err)
212
215
  process.exit(1)
213
216
  })
package/src/server.js CHANGED
@@ -7,9 +7,14 @@ import { Router as OssyRouter } from '@ossy/router'
7
7
  import cookieParser from 'cookie-parser'
8
8
  import { ProxyInternal } from './proxy-internal.js'
9
9
  import { SDK } from '@ossy/sdk'
10
+ import { AggregateRebuild } from '@ossy/event-store'
10
11
  import { TaskService } from './tasks/task-service.js'
11
12
  import { ChangeStream } from './tasks/change-stream.js'
12
13
  import { registerResourceTemplate } from './resources/resource-template.registry.js'
14
+ import { IntegrationService } from './integration.service.js'
15
+ import { createLogger } from '@ossy/observability'
16
+
17
+ const log = createLogger('@ossy/platform')
13
18
 
14
19
  const DEFAULT_PORT = 3000
15
20
  const MANIFEST_FILE = 'manifest.json'
@@ -49,9 +54,11 @@ export function loadManifest (buildDir) {
49
54
  const components = Array.isArray(manifest.components) ? manifest.components : []
50
55
  const resourceTemplates = Array.isArray(manifest.resourceTemplates) ? manifest.resourceTemplates : []
51
56
  const aggregates = Array.isArray(manifest.aggregates) ? manifest.aggregates : []
57
+ const integrations = Array.isArray(manifest.integrations) ? manifest.integrations : []
58
+ const startups = Array.isArray(manifest.startups) ? manifest.startups : []
52
59
  for (const e of entries) {
53
60
  if ((e.type === 'page' || e.type === 'api') && !validRoutable(e)) {
54
- console.warn(`[@ossy/platform][server] Skipping ${e.type} "${e.id}" — manifest entry has no path.`)
61
+ log.warn(`Skipping ${e.type} "${e.id}" — manifest entry has no path.`)
55
62
  }
56
63
  }
57
64
  return {
@@ -62,6 +69,8 @@ export function loadManifest (buildDir) {
62
69
  components,
63
70
  resourceTemplates,
64
71
  aggregates,
72
+ integrations,
73
+ startups,
65
74
  config: manifest.config || {},
66
75
  }
67
76
  }
@@ -100,7 +109,7 @@ export async function startServer (options = {}) {
100
109
  TaskService.registerTask(mod)
101
110
  }
102
111
  } catch (err) {
103
- console.error(`[@ossy/platform][server] Failed to load task "${task.id}":`, err.message)
112
+ log.error(`Failed to load task "${task.id}"`, undefined, err)
104
113
  }
105
114
  }
106
115
 
@@ -108,28 +117,46 @@ export async function startServer (options = {}) {
108
117
  registerResourceTemplate(template)
109
118
  }
110
119
 
111
- // Aggregate registrationmirrors task and resource-template registration above.
112
- // AggregateRebuild lives in the co-located api package. The relative path resolves
113
- // correctly both in the monorepo layout (ossy/packages/platform/src/ api/) and
114
- // when the published package is installed inside api/node_modules/@ossy/platform/src/.
115
- // A try/catch ensures the server starts cleanly even when the api source is absent.
116
- let AggregateRebuild = null
117
- try {
118
- const rebuildMod = await import(new URL('../../../../api/src/aggregate/aggregate.rebuild.js', import.meta.url).href)
119
- AggregateRebuild = rebuildMod.AggregateRebuild
120
- } catch {
121
- // AggregateRebuild not reachable from this installation — aggregate registration skipped.
120
+ // Integration loadingimport each bundled integration module, check its
121
+ // declared credentials, and call connect({ env }) to obtain a client.
122
+ // Missing credentials or a failing connect() are non-fatal: the integration
123
+ // is skipped with a warning so the server still starts.
124
+ const integrationModules = []
125
+ for (const intEntry of manifest.integrations ?? []) {
126
+ try {
127
+ integrationModules.push(await import(resolveEntryUrl(intEntry.entry, buildDir)))
128
+ } catch (err) {
129
+ log.warn(`Failed to import integration "${intEntry.id}"`, undefined, err)
130
+ }
122
131
  }
132
+ await IntegrationService.load(integrationModules, process.env)
123
133
 
134
+ // Aggregate registration — mirrors task and resource-template registration above.
135
+ // AggregateRebuild is provided by @ossy/event-store.
124
136
  if (AggregateRebuild) {
125
137
  for (const agg of manifest.aggregates ?? []) {
126
138
  try {
127
139
  const mod = await import(resolveEntryUrl(agg.entry, buildDir))
128
140
  AggregateRebuild.registerAggregate(mod)
129
141
  } catch (err) {
130
- console.error(`[@ossy/platform][server] Failed to load aggregate "${agg.id}":`, err.message)
142
+ log.error(`Failed to load aggregate "${agg.id}"`, undefined, err)
131
143
  }
132
144
  }
145
+
146
+ AggregateRebuild.BuildAndSaveAll().catch((error) => {
147
+ log.error('BuildAndSaveAll failed — is MongoDB reachable?', undefined, error)
148
+ })
149
+ }
150
+
151
+ for (const startup of manifest.startups ?? []) {
152
+ try {
153
+ const mod = await import(resolveEntryUrl(startup.entry, buildDir))
154
+ if (typeof mod.run === 'function') {
155
+ await mod.run({ env: process.env })
156
+ }
157
+ } catch (err) {
158
+ log.error(`Failed to run startup "${startup.id}"`, undefined, err)
159
+ }
133
160
  }
134
161
 
135
162
  // Register the SDK so all tasks receive it as `sdk`.
@@ -234,7 +261,7 @@ export async function startServer (options = {}) {
234
261
 
235
262
  res.status(404).send('Not found')
236
263
  } catch (err) {
237
- console.error('[@ossy/platform][server] Request handling failed:', err)
264
+ log.error('Request handling failed', undefined, err)
238
265
  if (!res.headersSent) {
239
266
  res.status(500).type('text').send('Internal Server Error')
240
267
  }
@@ -247,8 +274,8 @@ export async function startServer (options = {}) {
247
274
  httpServer.once('error', reject)
248
275
  })
249
276
 
250
- console.log(`[@ossy/platform][server] Running on http://localhost:${port}`)
251
- console.log('[@ossy/platform][server] Press Ctrl+C to stop.')
277
+ log.info(`Running on http://localhost:${port}`)
278
+ log.info('Press Ctrl+C to stop.')
252
279
 
253
280
  const closeServer = () => new Promise((resolve) => server.close(() => resolve()))
254
281
 
@@ -256,7 +283,7 @@ export async function startServer (options = {}) {
256
283
  const handleShutdown = async (signal) => {
257
284
  if (shuttingDown) return
258
285
  shuttingDown = true
259
- console.log(`\n[@ossy/platform][server] Received ${signal}, shutting down...`)
286
+ log.info(`Received ${signal}, shutting down...`)
260
287
  try {
261
288
  await closeServer()
262
289
  } finally {
@@ -2,6 +2,9 @@ import path from 'path'
2
2
  import os from 'os'
3
3
  import { mkdir, writeFile } from 'fs/promises'
4
4
  import { SDK, ResourcesList } from '@ossy/sdk'
5
+ import { createLogger } from '@ossy/observability'
6
+
7
+ const log = createLogger('platform')
5
8
 
6
9
  const CACHE_TTL_MS = 5 * 60 * 1000 // 5 minutes
7
10
  const DOWNLOAD_CONCURRENCY = 10
@@ -125,7 +128,7 @@ export async function loadSite (domain) {
125
128
  const buildDir = path.join(BASE_DIR, domain)
126
129
  const cmsLocation = `/@ossy/apps/${domain}`
127
130
 
128
- console.log(`[@ossy/platform] Loading site for ${domain} (workspace ${workspaceId}) from CMS ${cmsLocation}…`)
131
+ log.info(`[@ossy/platform] Loading site for ${domain} (workspace ${workspaceId}) from CMS ${cmsLocation}…`)
129
132
 
130
133
  const sdk = SDK.of({
131
134
  apiUrl: API_URL,
@@ -146,7 +149,7 @@ export async function loadSite (domain) {
146
149
 
147
150
  await runWithConcurrency(tasks, DOWNLOAD_CONCURRENCY)
148
151
 
149
- console.log(`[@ossy/platform] Site ${domain} ready — ${files.length} file(s) in ${buildDir}`)
152
+ log.info(`[@ossy/platform] Site ${domain} ready — ${files.length} file(s) in ${buildDir}`)
150
153
 
151
154
  cache.set(domain, { buildDir, workspaceId, loadedAt: Date.now() })
152
155
  return buildDir
@@ -158,5 +161,5 @@ export async function loadSite (domain) {
158
161
  */
159
162
  export function invalidateSite (domain) {
160
163
  cache.delete(domain)
161
- console.log(`[@ossy/platform] Cache invalidated for ${domain}`)
164
+ log.info(`[@ossy/platform] Cache invalidated for ${domain}`)
162
165
  }
@@ -1,4 +1,7 @@
1
1
  import { TaskService } from './task-service.js'
2
+ import { createLogger } from '@ossy/observability'
3
+
4
+ const log = createLogger('platform')
2
5
 
3
6
  function isMongoTopologyClosedError(error) {
4
7
  if (!error || typeof error !== 'object') return false
@@ -24,7 +27,7 @@ export class ChangeStream {
24
27
  ChangeStream._reconnectAttempts = 0
25
28
  ChangeStream._dbUrl = dbUrl
26
29
  ChangeStream._open().catch((error) => {
27
- console.error('[ChangeStream] Change stream could not be opened', error)
30
+ log.error('[ChangeStream] Change stream could not be opened', undefined, error)
28
31
  ChangeStream._scheduleReconnect()
29
32
  })
30
33
  }
@@ -61,7 +64,7 @@ export class ChangeStream {
61
64
  if (prev) {
62
65
  prev.close().catch(() => {})
63
66
  }
64
- console.log('[ChangeStream] New MongoClient instance created')
67
+ log.info('[ChangeStream] New MongoClient instance created')
65
68
  }
66
69
 
67
70
  static _scheduleReconnect() {
@@ -70,19 +73,19 @@ export class ChangeStream {
70
73
  const delaySecs = Math.min(Math.pow(2, ChangeStream._reconnectAttempts), 30)
71
74
  ChangeStream._reconnectAttempts++
72
75
 
73
- console.log(`[ChangeStream] Reconnecting in ${delaySecs}s...`)
76
+ log.info(`[ChangeStream] Reconnecting in ${delaySecs}s...`)
74
77
 
75
78
  setTimeout(() => {
76
79
  if (ChangeStream._stopped) return
77
80
  ChangeStream._open().catch((error) => {
78
- console.error('[ChangeStream] Change stream could not be opened', error)
81
+ log.error('[ChangeStream] Change stream could not be opened', undefined, error)
79
82
  ChangeStream._scheduleReconnect()
80
83
  })
81
84
  }, delaySecs * 1000)
82
85
  }
83
86
 
84
87
  static async _open() {
85
- console.log('[ChangeStream] Watching for changes')
88
+ log.info('[ChangeStream] Watching for changes')
86
89
 
87
90
  const dbName = process.env.DB_NAME || 'test'
88
91
  const client = await ChangeStream._getClient()
@@ -95,7 +98,7 @@ export class ChangeStream {
95
98
  const wasReconnecting = ChangeStream._reconnectAttempts > 0
96
99
  ChangeStream._reconnectAttempts = 0
97
100
  if (wasReconnecting) {
98
- console.log('[ChangeStream] Reconnected.')
101
+ log.info('[ChangeStream] Reconnected.')
99
102
  }
100
103
 
101
104
  // Ensures only one reconnect is scheduled if both 'error' and 'close' fire for the same failure.
@@ -108,7 +111,7 @@ export class ChangeStream {
108
111
  }
109
112
 
110
113
  changeStream.on('error', (error) => {
111
- console.error('[ChangeStream] Change stream error (server keeps running)', error)
114
+ log.error('[ChangeStream] Change stream error (server keeps running)', undefined, error)
112
115
  if (isMongoTopologyClosedError(error)) {
113
116
  ChangeStream._resetClient().catch(() => {})
114
117
  }
@@ -116,17 +119,17 @@ export class ChangeStream {
116
119
  })
117
120
 
118
121
  changeStream.on('change', (change) => {
119
- console.log('[ChangeStream] Change detected')
122
+ log.info('[ChangeStream] Change detected')
120
123
  TaskService.dispatch(change.fullDocument)
121
124
  })
122
125
 
123
126
  changeStream.on('close', () => {
124
- console.log('[ChangeStream] close detected')
127
+ log.info('[ChangeStream] close detected')
125
128
  scheduleOnce()
126
129
  })
127
130
 
128
131
  changeStream.on('end', () => {
129
- console.log('[ChangeStream] end detected')
132
+ log.info('[ChangeStream] end detected')
130
133
  scheduleOnce()
131
134
  })
132
135
  }
@@ -1,5 +1,8 @@
1
1
  import { discoverTaskFiles } from './task-loader.js'
2
2
  import { TaskService } from './task-service.js'
3
+ import { createLogger } from '@ossy/observability'
4
+
5
+ const log = createLogger('platform')
3
6
 
4
7
  export async function loadAndRegisterTasks({ cwd }) {
5
8
  const taskFiles = await discoverTaskFiles({ cwd })
@@ -11,7 +14,7 @@ export async function loadAndRegisterTasks({ cwd }) {
11
14
  TaskService.registerTask(taskModule)
12
15
  }
13
16
  } catch (err) {
14
- console.error(`[TaskService] Failed to load task ${fileUrl}:`, err.message)
17
+ log.error(`[TaskService] Failed to load task ${fileUrl}`, { message: err.message }, err)
15
18
  }
16
19
  }
17
20
  }
@@ -1,7 +1,10 @@
1
1
  import { matchesGlob } from './glob.js'
2
2
  import { matchesCron } from './cron.js'
3
+ import { IntegrationService } from '../integration.service.js'
4
+ import { createLogger, metrics } from '@ossy/observability'
3
5
 
4
6
  const SCHEDULER_INTERVAL_MS = 60_000
7
+ const _serviceLog = createLogger('TaskService')
5
8
 
6
9
  export class TaskService {
7
10
 
@@ -40,17 +43,17 @@ export class TaskService {
40
43
  const metadata = taskModule.metadata
41
44
 
42
45
  if (typeof handler !== 'function') {
43
- console.error(`[TaskService] Task "${metadata?.id}" has no exported "run" function — skipping`)
46
+ _serviceLog.error(`Task "${metadata?.id}" has no exported "run" function — skipping`)
44
47
  return
45
48
  }
46
49
  if (!metadata?.id) {
47
- console.error('[TaskService] Task has no metadata.id — skipping')
50
+ _serviceLog.error('Task has no metadata.id — skipping')
48
51
  return
49
52
  }
50
53
 
51
54
  TaskService._tasks.push({ metadata, handler })
52
- console.log(
53
- `[TaskService] Registered task "${metadata.id}" with ${metadata.triggers?.length ?? 0} trigger(s)` +
55
+ _serviceLog.info(
56
+ `Registered task "${metadata.id}" with ${metadata.triggers?.length ?? 0} trigger(s)` +
54
57
  (metadata.schedule ? ` and schedule "${metadata.schedule}"` : ''),
55
58
  )
56
59
  }
@@ -71,13 +74,19 @@ export class TaskService {
71
74
 
72
75
  if (!matched) continue
73
76
 
74
- console.log(`[TaskService] Dispatching task "${metadata.id}"`)
77
+ _serviceLog.info(`Dispatching task "${metadata.id}"`)
75
78
 
79
+ const _taskId = metadata.id
80
+ const _start = Date.now()
76
81
  Promise.resolve()
77
- .then(() => handler({ event, sdk: effectiveSdk }))
82
+ .then(() => handler({ event, sdk: effectiveSdk, integrations: IntegrationService, log: createLogger(metadata.id) }))
78
83
  .catch(error =>
79
- console.error(`[TaskService] Task "${metadata.id}" failed`, error),
84
+ _serviceLog.error(`Task "${metadata.id}" failed`, undefined, error),
80
85
  )
86
+ .finally(() => {
87
+ try { metrics.timing('task.duration', Date.now() - _start, { task: _taskId }) } catch {}
88
+ try { metrics.increment('task.run', { task: _taskId }) } catch {}
89
+ })
81
90
  }
82
91
  }
83
92
 
@@ -90,7 +99,7 @@ export class TaskService {
90
99
  const scheduledTasks = TaskService._tasks.filter(t => !!t.metadata.schedule)
91
100
  if (scheduledTasks.length === 0) return
92
101
 
93
- console.log(`[TaskService] Scheduler started for ${scheduledTasks.length} task(s)`)
102
+ _serviceLog.info(`Scheduler started for ${scheduledTasks.length} task(s)`)
94
103
 
95
104
  TaskService._stopped = false
96
105
  TaskService._schedulerInterval = setInterval(() => {
@@ -101,13 +110,19 @@ export class TaskService {
101
110
  for (const { metadata, handler } of scheduledTasks) {
102
111
  if (!matchesCron(metadata.schedule, now)) continue
103
112
 
104
- console.log(`[TaskService] Schedule fired for task "${metadata.id}"`)
113
+ _serviceLog.info(`Schedule fired for task "${metadata.id}"`)
105
114
 
115
+ const _taskId = metadata.id
116
+ const _start = Date.now()
106
117
  Promise.resolve()
107
- .then(() => handler({ event: { type: 'scheduled', taskId: metadata.id }, sdk: TaskService._sdk }))
118
+ .then(() => handler({ event: { type: 'scheduled', taskId: metadata.id }, sdk: TaskService._sdk, integrations: IntegrationService, log: createLogger(metadata.id) }))
108
119
  .catch(error =>
109
- console.error(`[TaskService] Scheduled task "${metadata.id}" failed`, error),
120
+ _serviceLog.error(`Scheduled task "${metadata.id}" failed`, undefined, error),
110
121
  )
122
+ .finally(() => {
123
+ try { metrics.timing('task.duration', Date.now() - _start, { task: _taskId }) } catch {}
124
+ try { metrics.increment('task.run', { task: _taskId }) } catch {}
125
+ })
111
126
  }
112
127
  }, SCHEDULER_INTERVAL_MS)
113
128
  }