@gokiteam/goki-dev 0.2.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.
Files changed (205) hide show
  1. package/README.md +478 -0
  2. package/bin/goki-dev.js +452 -0
  3. package/bin/mcp-server.js +16 -0
  4. package/bin/secrets-cli.js +302 -0
  5. package/cli/ComposeOverrideGenerator.js +226 -0
  6. package/cli/ComposeParser.js +73 -0
  7. package/cli/ConfigGenerator.js +304 -0
  8. package/cli/ConfigManager.js +46 -0
  9. package/cli/DatabaseManager.js +94 -0
  10. package/cli/DevToolsChecker.js +21 -0
  11. package/cli/DevToolsDir.js +66 -0
  12. package/cli/DevToolsManager.js +451 -0
  13. package/cli/DockerManager.js +138 -0
  14. package/cli/FunctionManager.js +95 -0
  15. package/cli/HttpProxyRewriter.js +91 -0
  16. package/cli/Logger.js +10 -0
  17. package/cli/McpConfigManager.js +123 -0
  18. package/cli/NgrokManager.js +431 -0
  19. package/cli/ProjectCLI.js +2322 -0
  20. package/cli/PubSubManager.js +129 -0
  21. package/cli/SnapshotManager.js +88 -0
  22. package/cli/UiFormatter.js +292 -0
  23. package/cli/WebhookUrlRewriter.js +32 -0
  24. package/cli/secrets/BiometricAuth.js +125 -0
  25. package/cli/secrets/SecretInjector.js +47 -0
  26. package/cli/secrets/SecretsConfig.js +141 -0
  27. package/cli/secrets/SecretsDoctor.js +384 -0
  28. package/cli/secrets/SecretsManager.js +255 -0
  29. package/client/dist/client.d.ts +332 -0
  30. package/client/dist/client.js +507 -0
  31. package/client/dist/helpers.d.ts +62 -0
  32. package/client/dist/helpers.js +122 -0
  33. package/client/dist/index.d.ts +59 -0
  34. package/client/dist/index.js +78 -0
  35. package/client/dist/package.json +1 -0
  36. package/client/dist/types.d.ts +280 -0
  37. package/client/dist/types.js +7 -0
  38. package/config.development +46 -0
  39. package/config.test +18 -0
  40. package/guidelines/CodingStyleGuideline.md +148 -0
  41. package/guidelines/CommentingGuideline.md +10 -0
  42. package/guidelines/HttpApiImplementationGuideline.md +137 -0
  43. package/guidelines/NamingGuideline.md +182 -0
  44. package/package.json +138 -0
  45. package/patterns/api/[collectionName]/Controllers.md +62 -0
  46. package/patterns/api/[collectionName]/Logic.md +154 -0
  47. package/patterns/api/[collectionName]/Permissions.md +81 -0
  48. package/patterns/api/[collectionName]/Router.md +83 -0
  49. package/patterns/api/[collectionName]/Schemas.md +197 -0
  50. package/patterns/configs/Patterns.md +7 -0
  51. package/patterns/enums/Patterns.md +24 -0
  52. package/patterns/errorHandling/Patterns.md +185 -0
  53. package/patterns/testing/Patterns.md +232 -0
  54. package/src/Server.js +238 -0
  55. package/src/api/dashboard/Controllers.js +9 -0
  56. package/src/api/dashboard/Logic.js +76 -0
  57. package/src/api/dashboard/Router.js +11 -0
  58. package/src/api/dashboard/Schemas.js +47 -0
  59. package/src/api/data/Controllers.js +26 -0
  60. package/src/api/data/Logic.js +188 -0
  61. package/src/api/data/Router.js +16 -0
  62. package/src/api/docker/Controllers.js +33 -0
  63. package/src/api/docker/Logic.js +268 -0
  64. package/src/api/docker/Router.js +15 -0
  65. package/src/api/docker/Schemas.js +80 -0
  66. package/src/api/docs/Controllers.js +15 -0
  67. package/src/api/docs/Logic.js +85 -0
  68. package/src/api/docs/Router.js +12 -0
  69. package/src/api/export/Controllers.js +30 -0
  70. package/src/api/export/Logic.js +143 -0
  71. package/src/api/export/Router.js +18 -0
  72. package/src/api/export/Schemas.js +104 -0
  73. package/src/api/firestore/Controllers.js +152 -0
  74. package/src/api/firestore/Logic.js +474 -0
  75. package/src/api/firestore/Router.js +23 -0
  76. package/src/api/functions/Controllers.js +261 -0
  77. package/src/api/functions/Logic.js +710 -0
  78. package/src/api/functions/Router.js +50 -0
  79. package/src/api/functions/Schemas.js +193 -0
  80. package/src/api/gateway/Controllers.js +72 -0
  81. package/src/api/gateway/Logic.js +74 -0
  82. package/src/api/gateway/Router.js +10 -0
  83. package/src/api/gateway/Schemas.js +19 -0
  84. package/src/api/health/Controllers.js +14 -0
  85. package/src/api/health/Logic.js +24 -0
  86. package/src/api/health/Router.js +12 -0
  87. package/src/api/httpTraffic/Controllers.js +29 -0
  88. package/src/api/httpTraffic/Logic.js +33 -0
  89. package/src/api/httpTraffic/Router.js +9 -0
  90. package/src/api/httpTraffic/Schemas.js +23 -0
  91. package/src/api/logging/Controllers.js +80 -0
  92. package/src/api/logging/Logic.js +461 -0
  93. package/src/api/logging/Router.js +24 -0
  94. package/src/api/logging/Schemas.js +43 -0
  95. package/src/api/mqtt/Controllers.js +17 -0
  96. package/src/api/mqtt/Logic.js +66 -0
  97. package/src/api/mqtt/Router.js +12 -0
  98. package/src/api/postgres/Controllers.js +97 -0
  99. package/src/api/postgres/Logic.js +221 -0
  100. package/src/api/postgres/Router.js +21 -0
  101. package/src/api/pubsub/Controllers.js +236 -0
  102. package/src/api/pubsub/Logic.js +732 -0
  103. package/src/api/pubsub/Router.js +41 -0
  104. package/src/api/pubsub/Schemas.js +355 -0
  105. package/src/api/redis/Controllers.js +63 -0
  106. package/src/api/redis/Logic.js +239 -0
  107. package/src/api/redis/Router.js +21 -0
  108. package/src/api/scheduler/Controllers.js +27 -0
  109. package/src/api/scheduler/Logic.js +49 -0
  110. package/src/api/scheduler/Router.js +16 -0
  111. package/src/api/services/Controllers.js +26 -0
  112. package/src/api/services/Logic.js +205 -0
  113. package/src/api/services/Router.js +14 -0
  114. package/src/api/services/Schemas.js +66 -0
  115. package/src/api/snapshots/Controllers.js +37 -0
  116. package/src/api/snapshots/Logic.js +797 -0
  117. package/src/api/snapshots/Router.js +15 -0
  118. package/src/api/snapshots/Schemas.js +23 -0
  119. package/src/api/webhooks/Controllers.js +49 -0
  120. package/src/api/webhooks/Logic.js +137 -0
  121. package/src/api/webhooks/Router.js +12 -0
  122. package/src/api/webhooks/Schemas.js +31 -0
  123. package/src/configs/Application.js +147 -0
  124. package/src/configs/Default.js +13 -0
  125. package/src/consumers/BlackboxLogsConsumer.js +235 -0
  126. package/src/consumers/DockerLogsConsumer.js +687 -0
  127. package/src/db/Tables.js +66 -0
  128. package/src/db/schemas/firestore.js +18 -0
  129. package/src/db/schemas/functions.js +65 -0
  130. package/src/db/schemas/httpTraffic.js +43 -0
  131. package/src/db/schemas/logging.js +74 -0
  132. package/src/db/schemas/migrations.js +64 -0
  133. package/src/db/schemas/mqtt.js +56 -0
  134. package/src/db/schemas/pubsub.js +90 -0
  135. package/src/db/schemas/pubsubRegistry.js +22 -0
  136. package/src/db/schemas/webhooks.js +28 -0
  137. package/src/emulation/awsiot/Controllers.js +91 -0
  138. package/src/emulation/awsiot/Logic.js +70 -0
  139. package/src/emulation/awsiot/Router.js +19 -0
  140. package/src/emulation/awsiot/Server.js +100 -0
  141. package/src/emulation/firestore/Server.js +136 -0
  142. package/src/emulation/logging/Controllers.js +212 -0
  143. package/src/emulation/logging/Logic.js +416 -0
  144. package/src/emulation/logging/Router.js +36 -0
  145. package/src/emulation/logging/Schemas.js +82 -0
  146. package/src/emulation/logging/Server.js +108 -0
  147. package/src/emulation/pubsub/Controllers.js +279 -0
  148. package/src/emulation/pubsub/DefaultTopics.js +162 -0
  149. package/src/emulation/pubsub/Logic.js +427 -0
  150. package/src/emulation/pubsub/README.md +309 -0
  151. package/src/emulation/pubsub/Router.js +33 -0
  152. package/src/emulation/pubsub/Server.js +104 -0
  153. package/src/emulation/pubsub/ShadowPoller.js +276 -0
  154. package/src/emulation/pubsub/ShadowSubscriptionManager.js +199 -0
  155. package/src/enums/ContainerNames.js +106 -0
  156. package/src/enums/ErrorReason.js +28 -0
  157. package/src/enums/FunctionStatuses.js +15 -0
  158. package/src/enums/FunctionTriggerTypes.js +15 -0
  159. package/src/enums/GatewayState.js +7 -0
  160. package/src/enums/ServiceNames.js +68 -0
  161. package/src/jobs/DatabaseMaintenance.js +184 -0
  162. package/src/jobs/MessageHistoryCleanup.js +152 -0
  163. package/src/mcp/ApiClient.js +25 -0
  164. package/src/mcp/Server.js +52 -0
  165. package/src/mcp/prompts/debugging.js +104 -0
  166. package/src/mcp/resources/platform.js +118 -0
  167. package/src/mcp/tools/data.js +84 -0
  168. package/src/mcp/tools/docker.js +166 -0
  169. package/src/mcp/tools/firestore.js +162 -0
  170. package/src/mcp/tools/functions.js +380 -0
  171. package/src/mcp/tools/httpTraffic.js +69 -0
  172. package/src/mcp/tools/logging.js +174 -0
  173. package/src/mcp/tools/mqtt.js +37 -0
  174. package/src/mcp/tools/postgres.js +130 -0
  175. package/src/mcp/tools/pubsub.js +316 -0
  176. package/src/mcp/tools/redis.js +146 -0
  177. package/src/mcp/tools/services.js +169 -0
  178. package/src/mcp/tools/snapshots.js +88 -0
  179. package/src/mcp/tools/webhooks.js +115 -0
  180. package/src/middleware/DevProxy.js +67 -0
  181. package/src/middleware/ErrorCatcher.js +35 -0
  182. package/src/middleware/HttpProxy.js +215 -0
  183. package/src/middleware/Reply.js +24 -0
  184. package/src/middleware/TraceId.js +9 -0
  185. package/src/middleware/WebhookProxy.js +234 -0
  186. package/src/protocols/mqtt/Broker.js +92 -0
  187. package/src/protocols/mqtt/Handlers.js +175 -0
  188. package/src/protocols/mqtt/PubSubBridge.js +162 -0
  189. package/src/protocols/mqtt/Server.js +116 -0
  190. package/src/runtime/FunctionRunner.js +179 -0
  191. package/src/services/AppGatewayService.js +582 -0
  192. package/src/singletons/FirestoreBroadcaster.js +367 -0
  193. package/src/singletons/FunctionTriggerDispatcher.js +456 -0
  194. package/src/singletons/FunctionsService.js +418 -0
  195. package/src/singletons/HttpProxy.js +224 -0
  196. package/src/singletons/LogBroadcaster.js +159 -0
  197. package/src/singletons/Logger.js +49 -0
  198. package/src/singletons/MemoryJsonStore.js +175 -0
  199. package/src/singletons/MessageBroadcaster.js +190 -0
  200. package/src/singletons/PostgresBroadcaster.js +367 -0
  201. package/src/singletons/PostgresClient.js +180 -0
  202. package/src/singletons/RedisClient.js +184 -0
  203. package/src/singletons/SqliteStore.js +480 -0
  204. package/src/singletons/TickService.js +151 -0
  205. package/src/singletons/WebhookProxy.js +223 -0
@@ -0,0 +1,687 @@
1
+ import Docker from 'dockerode'
2
+ import { PassThrough } from 'stream'
3
+ import { SqliteStore } from '../singletons/SqliteStore.js'
4
+ import { LOGGING_ENTRIES, LOGGING_LOGS } from '../db/Tables.js'
5
+ import { Logger } from '../singletons/Logger.js'
6
+ import { LogBroadcaster } from '../singletons/LogBroadcaster.js'
7
+
8
+ // Infrastructure containers to exclude from log capture
9
+ const DEFAULT_EXCLUDED_CONTAINERS = [
10
+ 'goki-dev-tools-backend',
11
+ 'goki-dev-tools-frontend',
12
+ 'goki-redis',
13
+ 'goki-pubsub-emulator',
14
+ 'goki-firestore-emulator'
15
+ ]
16
+
17
+ export class DockerLogsConsumer {
18
+ constructor (options = {}) {
19
+ this.docker = new Docker({ socketPath: '/var/run/docker.sock' })
20
+ this.streams = new Map()
21
+ this.lineBuffers = new Map()
22
+ this.enabled = options.enabled !== false
23
+ this.networkName = options.networkName || 'goki-network'
24
+ this.excludedContainers = options.excludedContainers || DEFAULT_EXCLUDED_CONTAINERS
25
+ }
26
+
27
+ async start () {
28
+ if (!this.enabled) {
29
+ Logger.log({
30
+ level: 'info',
31
+ message: 'Docker console log capture disabled'
32
+ })
33
+ return
34
+ }
35
+ try {
36
+ // Find all containers on the goki-network (except excluded infrastructure)
37
+ const containers = await this.docker.listContainers({
38
+ filters: { network: [this.networkName] }
39
+ })
40
+ const eligible = containers.filter(c => {
41
+ const name = c.Names[0].replace(/^\//, '')
42
+ return !this.excludedContainers.includes(name)
43
+ })
44
+ Logger.log({
45
+ level: 'info',
46
+ message: `Found ${eligible.length} containers for log capture on ${this.networkName}`,
47
+ data: {
48
+ capturing: eligible.map(c => c.Names[0]),
49
+ excluded: containers.length - eligible.length
50
+ }
51
+ })
52
+ for (const containerInfo of eligible) {
53
+ await this.attachToContainer(containerInfo)
54
+ }
55
+ this.watchForNewContainers()
56
+ } catch (error) {
57
+ Logger.log({
58
+ level: 'error',
59
+ message: 'Failed to start Docker log capture',
60
+ data: { error: error.message }
61
+ })
62
+ }
63
+ }
64
+
65
+ async attachToContainer (containerInfo) {
66
+ const containerId = containerInfo.Id
67
+ const containerName = containerInfo.Names[0].replace(/^\//, '')
68
+ try {
69
+ const container = this.docker.getContainer(containerId)
70
+ const containerDetails = await container.inspect()
71
+ const serviceName = containerDetails.Config.Labels?.['goki.service'] || containerName
72
+ const stream = await container.logs({
73
+ follow: true,
74
+ stdout: true,
75
+ stderr: true,
76
+ timestamps: true
77
+ })
78
+ Logger.log({
79
+ level: 'info',
80
+ message: 'Attached to container logs',
81
+ data: { container: containerName, service: serviceName }
82
+ })
83
+ const stdout = new PassThrough()
84
+ const stderr = new PassThrough()
85
+ this.docker.modem.demuxStream(stream, stdout, stderr)
86
+ stdout.on('data', (chunk) => {
87
+ this.parseLogLines(chunk.toString('utf-8'), containerName, serviceName, 1)
88
+ })
89
+ stderr.on('data', (chunk) => {
90
+ this.parseLogLines(chunk.toString('utf-8'), containerName, serviceName, 2)
91
+ })
92
+ stream.on('end', () => {
93
+ Logger.log({
94
+ level: 'info',
95
+ message: 'Container log stream ended',
96
+ data: { container: containerName, service: serviceName }
97
+ })
98
+ this.flushLineBuffer(containerName, serviceName, 1)
99
+ this.flushLineBuffer(containerName, serviceName, 2)
100
+ this.lineBuffers.delete(`${containerName}:1`)
101
+ this.lineBuffers.delete(`${containerName}:2`)
102
+ this.streams.delete(containerId)
103
+ })
104
+ stream.on('error', (error) => {
105
+ Logger.log({
106
+ level: 'error',
107
+ message: 'Container log stream error',
108
+ data: { container: containerName, service: serviceName, error: error.message }
109
+ })
110
+ })
111
+ this.streams.set(containerId, stream)
112
+ } catch (error) {
113
+ Logger.log({
114
+ level: 'error',
115
+ message: 'Failed to attach to container',
116
+ data: { container: containerName, error: error.message }
117
+ })
118
+ }
119
+ }
120
+
121
+ getLineBuffer (containerName, streamType) {
122
+ const key = `${containerName}:${streamType}`
123
+ if (!this.lineBuffers.has(key)) {
124
+ this.lineBuffers.set(key, { lines: [], braceDepth: 0, firstTimestamp: null, timer: null })
125
+ }
126
+ return this.lineBuffers.get(key)
127
+ }
128
+
129
+ flushLineBuffer (containerName, serviceName, streamType) {
130
+ const buf = this.getLineBuffer(containerName, streamType)
131
+ if (buf.timer) {
132
+ clearTimeout(buf.timer)
133
+ buf.timer = null
134
+ }
135
+ if (buf.lines.length === 0) return
136
+ const merged = buf.lines.join('\n')
137
+ const timestamp = buf.firstTimestamp
138
+ buf.lines = []
139
+ buf.braceDepth = 0
140
+ buf.firstTimestamp = null
141
+ this.processLogLine(timestamp ? `${timestamp} ${merged}` : merged, containerName, serviceName, streamType)
142
+ }
143
+
144
+ parseLogLines (data, containerName, serviceName, streamType) {
145
+ const lines = data.split('\n').filter(line => line.trim())
146
+ for (const line of lines) {
147
+ const timestampMatch = line.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)\s+(.*)/)
148
+ const timestamp = timestampMatch ? timestampMatch[1] : null
149
+ const content = timestampMatch ? timestampMatch[2] : line
150
+ const buf = this.getLineBuffer(containerName, streamType)
151
+ // Inside an active multi-line object buffer
152
+ if (buf.braceDepth > 0) {
153
+ buf.lines.push(content)
154
+ buf.braceDepth += (content.match(/\{/g) || []).length
155
+ buf.braceDepth -= (content.match(/\}/g) || []).length
156
+ if (buf.braceDepth <= 0) {
157
+ buf.braceDepth = 0
158
+ this.flushLineBuffer(containerName, serviceName, streamType)
159
+ } else {
160
+ this.resetBufferTimer(containerName, serviceName, streamType)
161
+ }
162
+ continue
163
+ }
164
+ const trimmed = content.trim()
165
+ // Opening brace on its own line — start new object buffer
166
+ if (trimmed === '{') {
167
+ if (buf.lines.length > 0) {
168
+ this.flushLineBuffer(containerName, serviceName, streamType)
169
+ }
170
+ buf.lines.push(content)
171
+ buf.braceDepth = 1
172
+ buf.firstTimestamp = timestamp
173
+ this.resetBufferTimer(containerName, serviceName, streamType)
174
+ continue
175
+ }
176
+ // Orphan object property line (from a previous chunk that split mid-object)
177
+ // Pattern: " key: value," or " key: {" — starts with optional whitespace + word + colon
178
+ if (this.isObjectPropertyLine(trimmed)) {
179
+ if (!buf.firstTimestamp) buf.firstTimestamp = timestamp
180
+ buf.lines.push(content)
181
+ buf.braceDepth += (content.match(/\{/g) || []).length
182
+ buf.braceDepth -= (content.match(/\}/g) || []).length
183
+ if (buf.braceDepth < 0) buf.braceDepth = 0
184
+ this.resetBufferTimer(containerName, serviceName, streamType)
185
+ continue
186
+ }
187
+ // Lone closing brace — end of orphan object from previous chunk
188
+ if (trimmed === '}' && buf.lines.length > 0) {
189
+ buf.lines.push(content)
190
+ buf.braceDepth = 0
191
+ this.flushLineBuffer(containerName, serviceName, streamType)
192
+ continue
193
+ }
194
+ this.processLogLine(line, containerName, serviceName, streamType)
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Detect if a line looks like a JS object property (key: value pattern).
200
+ * Used to identify orphan continuation lines from objects split across chunks.
201
+ */
202
+ isObjectPropertyLine (trimmed) {
203
+ // Match: word followed by colon and a value (string, number, object, array, etc.)
204
+ // e.g. "level: 'info'," or "messageAttributes: { traceId: 'abc' }," or "count: 42,"
205
+ // But NOT things like "INFO: Starting server" (uppercase log level prefix)
206
+ if (/^\w+:\s+/.test(trimmed)) {
207
+ // Exclude plain text log patterns: "LEVEL: message"
208
+ if (/^(INFO|WARNING|WARN|ERROR|SEVERE|DEBUG|FINE|FINER|FINEST|CONFIG):\s/i.test(trimmed)) {
209
+ return false
210
+ }
211
+ return true
212
+ }
213
+ return false
214
+ }
215
+
216
+ resetBufferTimer (containerName, serviceName, streamType) {
217
+ const buf = this.getLineBuffer(containerName, streamType)
218
+ if (buf.timer) clearTimeout(buf.timer)
219
+ buf.timer = setTimeout(() => {
220
+ this.flushLineBuffer(containerName, serviceName, streamType)
221
+ }, 500)
222
+ }
223
+
224
+ tryParseJsObject (text) {
225
+ try {
226
+ return JSON.parse(text)
227
+ } catch {
228
+ // Try converting JS object notation to JSON
229
+ try {
230
+ // Quote unquoted keys and convert single-quoted values to double-quoted
231
+ let jsonified = text
232
+ // Replace all single-quoted strings with double-quoted (handles colons in values)
233
+ jsonified = jsonified.replace(/'([^']*)'/g, (_, content) => `"${content.replace(/"/g, '\\"')}"`)
234
+
235
+ // Quote unquoted object keys (word chars before colon, not inside quotes)
236
+ jsonified = jsonified.replace(/([{,]\s*)(\w+)\s*:/g, '$1"$2":')
237
+ // Remove trailing commas
238
+ jsonified = jsonified.replace(/,(\s*[}\]])/g, '$1')
239
+ return JSON.parse(jsonified)
240
+ } catch {
241
+ return null
242
+ }
243
+ }
244
+ }
245
+
246
+ async processLogLine (line, containerName, serviceName, streamType) {
247
+ try {
248
+ const timestampMatch = line.match(/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z)\s+([\s\S]*)/)
249
+ const timestamp = timestampMatch ? timestampMatch[1] : new Date().toISOString()
250
+ const message = timestampMatch ? timestampMatch[2] : line
251
+ let logEntry, dedupeKey
252
+ try {
253
+ const parsed = JSON.parse(message)
254
+ logEntry = this.parseWinstonLog(parsed, containerName, serviceName, timestamp)
255
+ dedupeKey = {
256
+ timestamp: parsed.timestamp,
257
+ message: parsed.message,
258
+ level: parsed.level
259
+ }
260
+ } catch {
261
+ const jsObj = message.includes('\n') ? this.tryParseJsObject(message) : null
262
+ if (jsObj) {
263
+ logEntry = this.parseWinstonLog(jsObj, containerName, serviceName, timestamp)
264
+ logEntry.protoPayload = message
265
+ dedupeKey = {
266
+ timestamp: jsObj.timestamp || timestamp,
267
+ message: jsObj.message,
268
+ level: jsObj.level
269
+ }
270
+ } else {
271
+ const isMultiLineObject = message.includes('\n') && message.trim().startsWith('{')
272
+ if (isMultiLineObject) {
273
+ logEntry = this.parseConsoleObject(message, containerName, serviceName, timestamp)
274
+ } else {
275
+ logEntry = this.parsePlainTextLog(message, containerName, serviceName, timestamp, streamType)
276
+ }
277
+ dedupeKey = {
278
+ timestamp,
279
+ textPayload: logEntry.textPayload
280
+ }
281
+ }
282
+ }
283
+ const timeWindow = 2000
284
+ const timestampDate = new Date(dedupeKey.timestamp)
285
+ const textToCheck = dedupeKey.message || dedupeKey.textPayload
286
+ const startTime = new Date(timestampDate.getTime() - timeWindow).toISOString()
287
+ const endTime = new Date(timestampDate.getTime() + timeWindow).toISOString()
288
+ const isDuplicate = SqliteStore.exists(LOGGING_ENTRIES,
289
+ 'text_payload = ? AND service_name = ? AND timestamp >= ? AND timestamp <= ?',
290
+ [textToCheck, serviceName, startTime, endTime]
291
+ )
292
+ if (isDuplicate) {
293
+ return
294
+ }
295
+ const logId = serviceName || containerName
296
+ try {
297
+ SqliteStore.create(LOGGING_LOGS, {
298
+ projectId: 'dev',
299
+ logId,
300
+ logName: `projects/dev/logs/${serviceName}`,
301
+ createdAt: new Date().toISOString(),
302
+ updatedAt: new Date().toISOString()
303
+ })
304
+ } catch (error) {
305
+ if (!error.message.includes('UNIQUE')) {
306
+ throw error
307
+ }
308
+ }
309
+ await SqliteStore.create(LOGGING_ENTRIES, logEntry)
310
+ LogBroadcaster.broadcast(logEntry)
311
+ } catch (error) {
312
+ Logger.log({
313
+ level: 'error',
314
+ message: 'Failed to process log line',
315
+ data: { container: containerName, error: error.message }
316
+ })
317
+ }
318
+ }
319
+
320
+ findNestedValue (obj, key, maxDepth = 3) {
321
+ if (!obj || typeof obj !== 'object' || maxDepth <= 0) return undefined
322
+ if (obj[key] !== undefined) return obj[key]
323
+ for (const val of Object.values(obj)) {
324
+ if (val && typeof val === 'object') {
325
+ const found = this.findNestedValue(val, key, maxDepth - 1)
326
+ if (found !== undefined) return found
327
+ }
328
+ }
329
+ return undefined
330
+ }
331
+
332
+ parseWinstonLog (parsed, containerName, serviceName, timestamp) {
333
+ const severityMap = {
334
+ error: 'ERROR',
335
+ warn: 'WARNING',
336
+ info: 'INFO',
337
+ http: 'INFO',
338
+ verbose: 'DEBUG',
339
+ debug: 'DEBUG'
340
+ }
341
+ const logName = `projects/dev/logs/${serviceName}`
342
+ const severity = severityMap[parsed.level] || 'INFO'
343
+ // Merge log.metadata into labels (minus traceId/requestId which get own columns)
344
+ const metadata = parsed.metadata || {}
345
+ const labels = { container: containerName, ...metadata }
346
+ delete labels.traceId
347
+ delete labels.requestId
348
+ // Build jsonPayload: original object minus standard logger envelope fields
349
+ // These fields are already extracted into dedicated columns
350
+ const envelopeFields = ['level', 'message', 'msg', 'timestamp', 'metadata']
351
+ const payload = {}
352
+ for (const [key, value] of Object.entries(parsed)) {
353
+ if (!envelopeFields.includes(key)) {
354
+ payload[key] = value
355
+ }
356
+ }
357
+ const hasPayload = Object.keys(payload).length > 0
358
+ const logEntry = {
359
+ logName,
360
+ serviceName,
361
+ severity,
362
+ source: 'docker',
363
+ level: parsed.level || null,
364
+ timestamp: parsed.timestamp || timestamp,
365
+ receiveTimestamp: new Date().toISOString(),
366
+ textPayload: parsed.message || parsed.msg || parsed.event,
367
+ jsonPayload: hasPayload ? payload : null,
368
+ labels
369
+ }
370
+ // Extract traceId — check metadata first, then search entire object
371
+ const traceId = metadata.traceId || this.findNestedValue(parsed, 'traceId')
372
+ if (traceId) logEntry.trace = traceId
373
+ // Extract requestId
374
+ const requestId = metadata.requestId || parsed.requestId
375
+ if (requestId) logEntry.insertId = requestId
376
+ // Extract error info and stack trace
377
+ const errorObj = parsed.error || metadata.error
378
+ if (errorObj || parsed.stack) {
379
+ if (typeof errorObj === 'string') {
380
+ logEntry.errorMessage = errorObj
381
+ } else if (errorObj) {
382
+ logEntry.errorMessage = errorObj.message || parsed.message
383
+ const stack = errorObj.stack || parsed.stack
384
+ if (stack && typeof stack === 'string') logEntry.stackTrace = stack
385
+ // Extract @gokiteam/oops error.data fields
386
+ const errorData = errorObj.data
387
+ if (errorData && typeof errorData === 'object') {
388
+ if (errorData.code) logEntry.errorCode = String(errorData.code)
389
+ if (errorData.status) logEntry.errorStatus = errorData.status
390
+ if (errorData.reason) logEntry.errorReason = errorData.reason
391
+ if (errorData.resource) logEntry.errorResource = errorData.resource
392
+ // Extract entity IDs from error.data.meta
393
+ const meta = errorData.meta
394
+ if (meta && typeof meta === 'object') {
395
+ if (meta.propertyId) logEntry.propertyId = meta.propertyId
396
+ if (meta.deviceId) logEntry.deviceId = meta.deviceId
397
+ if (meta.doorId) logEntry.doorId = meta.doorId
398
+ }
399
+ }
400
+ } else {
401
+ // parsed.stack without error object
402
+ logEntry.errorMessage = parsed.message
403
+ if (typeof parsed.stack === 'string') logEntry.stackTrace = parsed.stack
404
+ }
405
+ }
406
+ // Extract event (LogEvent enum - very common across all services)
407
+ if (parsed.event) logEntry.event = parsed.event
408
+ // Extract duration (timing metrics)
409
+ if (parsed.duration !== undefined && parsed.duration !== null) {
410
+ logEntry.duration = Number(parsed.duration) || null
411
+ }
412
+ // Extract Pub/Sub subscriber fields
413
+ if (parsed.subscriptionName) logEntry.subscriptionName = parsed.subscriptionName
414
+ if (parsed.messageId) logEntry.messageId = parsed.messageId
415
+ // Extract domain entity IDs (spread at top level in Winston data)
416
+ if (parsed.propertyId && !logEntry.propertyId) logEntry.propertyId = parsed.propertyId
417
+ if (parsed.deviceId && !logEntry.deviceId) logEntry.deviceId = parsed.deviceId
418
+ if (parsed.doorId && !logEntry.doorId) logEntry.doorId = parsed.doorId
419
+ return logEntry
420
+ }
421
+
422
+ /**
423
+ * Parse a multi-line console.log() object that couldn't be converted to JSON.
424
+ * Extracts message, level, error, and traceId via regex from the raw text.
425
+ * The full original text is preserved in protoPayload since we can't reliably
426
+ * parse the original structure (contains non-JSON values like Buffer, Circular, etc.).
427
+ */
428
+ parseConsoleObject (text, containerName, serviceName, timestamp) {
429
+ const logName = `projects/dev/logs/${serviceName}`
430
+ const labels = { container: containerName }
431
+ // Extract key fields from JS object notation via regex
432
+ const extractField = (key) => {
433
+ const match = text.match(new RegExp(`${key}:\\s*'([^']*)'`))
434
+ return match ? match[1] : null
435
+ }
436
+ const message = extractField('message') || extractField('msg') || extractField('event')
437
+ const level = extractField('level')
438
+ const traceId = extractField('traceId')
439
+ const error = extractField('error')
440
+ const severityMap = { error: 'ERROR', warn: 'WARNING', info: 'INFO', debug: 'DEBUG' }
441
+ const logEntry = {
442
+ logName,
443
+ serviceName,
444
+ severity: severityMap[level] || this.detectSeverity(text),
445
+ source: 'docker',
446
+ level: level || null,
447
+ timestamp: extractField('timestamp') || timestamp,
448
+ receiveTimestamp: new Date().toISOString(),
449
+ textPayload: message || text.split('\n').map(l => l.trim()).join(' '),
450
+ protoPayload: text,
451
+ labels
452
+ }
453
+ if (traceId) logEntry.trace = traceId
454
+ if (error) logEntry.errorMessage = error
455
+ // Extract stack trace from raw text
456
+ // JS object notation uses string concatenation: stack: 'Error\n' + ' at func (file:1:1)\n' + ...
457
+ // Reconstruct by extracting all quoted segments after "stack:" and joining them
458
+ const stackFieldMatch = text.match(/stack:\s*'/)
459
+ if (stackFieldMatch) {
460
+ const startIdx = stackFieldMatch.index + stackFieldMatch[0].length - 1
461
+ const stackStr = this.extractConcatenatedString(text, startIdx)
462
+ if (stackStr && stackStr.includes('\n at ')) {
463
+ logEntry.stackTrace = stackStr
464
+ }
465
+ }
466
+ // Extract event field
467
+ const event = extractField('event')
468
+ if (event) logEntry.event = event
469
+ // Extract duration (number, not quoted)
470
+ const durationMatch = text.match(/duration:\s*(\d+(?:\.\d+)?)/)
471
+ if (durationMatch) logEntry.duration = Number(durationMatch[1]) || null
472
+ // Extract Pub/Sub fields
473
+ const subscriptionName = extractField('subscriptionName')
474
+ if (subscriptionName) logEntry.subscriptionName = subscriptionName
475
+ const messageId = extractField('messageId')
476
+ if (messageId) logEntry.messageId = messageId
477
+ // Extract domain entity IDs
478
+ const propertyId = extractField('propertyId')
479
+ if (propertyId) logEntry.propertyId = propertyId
480
+ const deviceId = extractField('deviceId')
481
+ if (deviceId) logEntry.deviceId = deviceId
482
+ const doorId = extractField('doorId')
483
+ if (doorId) logEntry.doorId = doorId
484
+ // Extract error.data fields (code, status, reason, resource)
485
+ const errorCode = extractField('code')
486
+ if (errorCode) logEntry.errorCode = errorCode
487
+ const errorReason = extractField('reason')
488
+ if (errorReason) logEntry.errorReason = errorReason
489
+ const errorResource = extractField('resource')
490
+ if (errorResource) logEntry.errorResource = errorResource
491
+ const statusMatch = text.match(/status:\s*(\d+)/)
492
+ if (statusMatch) logEntry.errorStatus = Number(statusMatch[1]) || null
493
+ return logEntry
494
+ }
495
+
496
+ /**
497
+ * Extract a concatenated string from JS object notation starting at a quote.
498
+ * Handles: 'part1\n' + ' part2\n' + ' part3'
499
+ * Returns the joined string or null.
500
+ */
501
+ extractConcatenatedString (text, startIdx) {
502
+ const parts = []
503
+ let pos = startIdx
504
+ while (pos < text.length) {
505
+ // Expect a single-quoted string
506
+ if (text[pos] !== "'") break
507
+ const endQuote = text.indexOf("'", pos + 1)
508
+ if (endQuote === -1) break
509
+ parts.push(text.substring(pos + 1, endQuote))
510
+ pos = endQuote + 1
511
+ // Skip whitespace and look for + concatenation
512
+ const afterQuote = text.substring(pos).match(/^\s*\+\s*/)
513
+ if (afterQuote) {
514
+ pos += afterQuote[0].length
515
+ } else {
516
+ break
517
+ }
518
+ }
519
+ if (parts.length === 0) return null
520
+ // Unescape \\n → \n within the joined string
521
+ return parts.join('').replace(/\\n/g, '\n').replace(/\\'/g, "'").replace(/\\t/g, '\t')
522
+ }
523
+
524
+ /**
525
+ * Parse structured plain-text log formats (Java logger, tagged logs, etc.)
526
+ * Supports:
527
+ * [tag] LEVEL: message
528
+ * [tag] Jan 01, 2026 12:00:00 AM class.method
529
+ * LEVEL: message
530
+ * Plain text fallback
531
+ */
532
+ parsePlainTextLog (message, containerName, serviceName, timestamp, streamType) {
533
+ const logName = `projects/dev/logs/${serviceName}`
534
+ const labels = {
535
+ container: containerName,
536
+ stream: streamType === 1 ? 'stdout' : 'stderr'
537
+ }
538
+ // Try tagged format: [tag] LEVEL: message OR [tag] Java date class
539
+ const taggedMatch = message.match(/^\[([^\]]+)\]\s+(.*)$/)
540
+ if (taggedMatch) {
541
+ const tag = taggedMatch[1]
542
+ const rest = taggedMatch[2]
543
+ labels.tag = tag
544
+ // Check for LEVEL: message pattern
545
+ const levelMsgMatch = rest.match(/^(INFO|WARNING|WARN|ERROR|SEVERE|DEBUG|FINE|FINER|FINEST|CONFIG):\s*(.*)$/i)
546
+ if (levelMsgMatch) {
547
+ const severity = this.mapJavaSeverity(levelMsgMatch[1])
548
+ return {
549
+ logName,
550
+ serviceName,
551
+ severity,
552
+ source: 'docker',
553
+ level: levelMsgMatch[1].toLowerCase(),
554
+ timestamp,
555
+ receiveTimestamp: new Date().toISOString(),
556
+ textPayload: levelMsgMatch[2],
557
+ labels
558
+ }
559
+ }
560
+ // Check for Java date pattern: Feb 19, 2026 11:21:41 AM class.method
561
+ const javaDateMatch = rest.match(/^[A-Z][a-z]{2}\s+\d{1,2},\s+\d{4}\s+\d{1,2}:\d{2}:\d{2}\s+[AP]M\s+(.*)$/)
562
+ if (javaDateMatch) {
563
+ return {
564
+ logName,
565
+ serviceName,
566
+ severity: 'DEBUG',
567
+ source: 'docker',
568
+ level: 'debug',
569
+ timestamp,
570
+ receiveTimestamp: new Date().toISOString(),
571
+ textPayload: javaDateMatch[1],
572
+ labels
573
+ }
574
+ }
575
+ // Tagged but unrecognized format — use rest as message
576
+ return {
577
+ logName,
578
+ serviceName,
579
+ severity: this.detectSeverity(rest),
580
+ source: 'docker',
581
+ level: null,
582
+ timestamp,
583
+ receiveTimestamp: new Date().toISOString(),
584
+ textPayload: rest,
585
+ labels
586
+ }
587
+ }
588
+ // Untagged LEVEL: message (e.g., "INFO: starting server")
589
+ const levelMatch = message.match(/^(INFO|WARNING|WARN|ERROR|SEVERE|DEBUG):\s*(.*)$/i)
590
+ if (levelMatch) {
591
+ const severity = this.mapJavaSeverity(levelMatch[1])
592
+ return {
593
+ logName,
594
+ serviceName,
595
+ severity,
596
+ source: 'docker',
597
+ level: levelMatch[1].toLowerCase(),
598
+ timestamp,
599
+ receiveTimestamp: new Date().toISOString(),
600
+ textPayload: levelMatch[2],
601
+ labels
602
+ }
603
+ }
604
+ // Plain text fallback
605
+ return {
606
+ logName,
607
+ serviceName,
608
+ severity: this.detectSeverity(message),
609
+ source: 'docker',
610
+ level: null,
611
+ timestamp,
612
+ receiveTimestamp: new Date().toISOString(),
613
+ textPayload: message,
614
+ labels
615
+ }
616
+ }
617
+
618
+ mapJavaSeverity (level) {
619
+ const map = {
620
+ severe: 'ERROR',
621
+ error: 'ERROR',
622
+ warning: 'WARNING',
623
+ warn: 'WARNING',
624
+ info: 'INFO',
625
+ config: 'INFO',
626
+ fine: 'DEBUG',
627
+ finer: 'DEBUG',
628
+ finest: 'DEBUG',
629
+ debug: 'DEBUG'
630
+ }
631
+ return map[level.toLowerCase()] || 'INFO'
632
+ }
633
+
634
+ detectSeverity (text) {
635
+ if (/error|exception|fatal|severe/i.test(text)) return 'ERROR'
636
+ if (/warn/i.test(text)) return 'WARNING'
637
+ if (/debug|trace/i.test(text)) return 'DEBUG'
638
+ return 'INFO'
639
+ }
640
+
641
+ watchForNewContainers () {
642
+ this.docker.getEvents({ filters: { event: ['start'] } }, (err, stream) => {
643
+ if (err) {
644
+ Logger.log({
645
+ level: 'error',
646
+ message: 'Failed to watch Docker events',
647
+ data: { error: err.message }
648
+ })
649
+ return
650
+ }
651
+ stream.on('data', async (chunk) => {
652
+ try {
653
+ const event = JSON.parse(chunk.toString())
654
+ const containerId = event.id
655
+ const container = this.docker.getContainer(containerId)
656
+ const info = await container.inspect()
657
+ const containerName = info.Name.replace(/^\//, '')
658
+ // Skip excluded infrastructure containers
659
+ if (this.excludedContainers.includes(containerName)) return
660
+ // Skip if already attached
661
+ if (this.streams.has(containerId)) return
662
+ // Check if container is on the target network
663
+ const networks = info.NetworkSettings?.Networks || {}
664
+ if (networks[this.networkName]) {
665
+ await this.attachToContainer({
666
+ Id: containerId,
667
+ Names: [info.Name]
668
+ })
669
+ }
670
+ } catch (error) {
671
+ // Ignore errors from short-lived containers
672
+ }
673
+ })
674
+ })
675
+ }
676
+
677
+ async stop () {
678
+ for (const [containerId, stream] of this.streams) {
679
+ stream.destroy()
680
+ }
681
+ this.streams.clear()
682
+ Logger.log({
683
+ level: 'info',
684
+ message: 'Docker log capture stopped'
685
+ })
686
+ }
687
+ }