Skip to content

Commit 0bd363e

Browse files
feat: stream logs to Garden Cloud via gRPC (#7793)
* chore: change createActionLog signature * feat: stream logs to Garden Cloud via gRPC --------- Co-authored-by: Jon Edvald <edvald@gmail.com>
1 parent d306e66 commit 0bd363e

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

64 files changed

+337
-206
lines changed

core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
"types": "build/src/index.d.ts",
2727
"dependencies": {
2828
"@bufbuild/protobuf": "2.8.0",
29-
"@buf/garden_grow-platform.bufbuild_es": "2.8.0-20251027135652-778164cdd988.1",
29+
"@buf/garden_grow-platform.bufbuild_es": "2.8.0-00000000000000-b98a6468c359.1",
3030
"@codenamize/codenamize": "^1.1.1",
3131
"@connectrpc/connect": "2.1.0",
3232
"@connectrpc/connect-node": "2.0.4",

core/src/actions/base.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -757,8 +757,7 @@ export abstract class BaseAction<
757757

758758
return createActionLog({
759759
log,
760-
actionKind: this.kind,
761-
actionName: this.name,
760+
action: this,
762761
})
763762
}
764763

core/src/actions/helpers.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ export async function getActionState(
110110
graph: ResolvedConfigGraph,
111111
log: Log
112112
): Promise<ActionState> {
113-
const actionLog = createActionLog({ log, actionName: action.name, actionKind: action.kind })
113+
const actionLog = createActionLog({ log, action })
114114
switch (action.kind) {
115115
case "Build":
116116
return (await router.build.getStatus({ action: action as ResolvedBuildAction, log: actionLog, graph }))?.result
@@ -156,7 +156,7 @@ export async function getDeployStatusPayloads({
156156
await Promise.all(
157157
actions.map(async (action) => {
158158
const startedAt = new Date().toISOString()
159-
const actionLog = createActionLog({ log, actionName: action.name, actionKind: action.kind })
159+
const actionLog = createActionLog({ log, action })
160160
const { result } = await router.deploy.getStatus({ action, log: actionLog, graph })
161161

162162
const payload = makeActionCompletePayload({
@@ -194,7 +194,7 @@ export async function getBuildStatusPayloads({
194194
await Promise.all(
195195
actions.map(async (action) => {
196196
const startedAt = new Date().toISOString()
197-
const actionLog = createActionLog({ log, actionName: action.name, actionKind: action.kind })
197+
const actionLog = createActionLog({ log, action })
198198
const { result } = await router.build.getStatus({ action, log: actionLog, graph })
199199

200200
const payload = makeActionCompletePayload({
@@ -230,7 +230,7 @@ export async function getTestStatusPayloads({
230230
return fromPairs(
231231
await Promise.all(
232232
actions.map(async (action) => {
233-
const actionLog = createActionLog({ log, actionName: action.name, actionKind: action.kind })
233+
const actionLog = createActionLog({ log, action })
234234
const startedAt = new Date().toISOString()
235235
const { result } = await router.test.getResult({ action, log: actionLog, graph })
236236
const payload = makeActionCompletePayload({
@@ -266,7 +266,7 @@ export async function getRunStatusPayloads({
266266
return fromPairs(
267267
await Promise.all(
268268
actions.map(async (action) => {
269-
const actionLog = createActionLog({ log, actionName: action.name, actionKind: action.kind })
269+
const actionLog = createActionLog({ log, action })
270270
const startedAt = new Date().toISOString()
271271
const { result } = await router.run.getResult({ action, log: actionLog, graph })
272272

core/src/cli/cli.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,9 @@ ${renderCommands(commands)}
312312
}
313313
}
314314

315+
// Wire root logger Garden instance event bus
316+
log.root.events = garden.events
317+
315318
analytics = await garden.getAnalyticsHandler()
316319

317320
// Register log file writers. We need to do this after the Garden class is initialised because

core/src/cloud/api/grpc-event-converter.ts

Lines changed: 100 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ import {
1616
} from "@buf/garden_grow-platform.bufbuild_es/garden/public/events/v1/events_pb.js"
1717
import type { EventName as CoreEventName, EventPayload as CoreEventPayload } from "../../events/events.js"
1818
import type { GardenWithNewBackend } from "../../garden.js"
19-
import type { Log } from "../../logger/log-entry.js"
19+
import type { LogSymbol, Msg } from "../../logger/log-entry.js"
20+
import { isActionLogContext, isCoreLogContext, type Log } from "../../logger/log-entry.js"
2021
import { monotonicFactory, ulid, type ULID, type UUID } from "ulid"
2122
import { create } from "@bufbuild/protobuf"
2223
import {
@@ -49,6 +50,12 @@ import {
4950
DeployRunResultSchema,
5051
} from "@buf/garden_grow-platform.bufbuild_es/garden/public/events/v1/garden_action_pb.js"
5152
import type { DeployStatusForEventPayload } from "../../types/service.js"
53+
import {
54+
DataFormat,
55+
GardenLogMessageEmittedSchema,
56+
LogSymbol as GrpcLogSymbol,
57+
} from "@buf/garden_grow-platform.bufbuild_es/garden/public/events/v1/garden_logs_pb.js"
58+
import type { LogEntryEventPayload } from "../api-legacy/restful-event-stream.js"
5259

5360
const nextEventUlid = monotonicFactory()
5461

@@ -73,6 +80,7 @@ const aecEnvironmentUpdateActionTriggeredMap = {
7380
export class GrpcEventConverter {
7481
private readonly garden: GardenWithNewBackend
7582
private readonly log: Log
83+
private readonly shouldStreamLogEntries: boolean
7684

7785
/**
7886
* It is important to keep it static,
@@ -81,9 +89,10 @@ export class GrpcEventConverter {
8189
*/
8290
private static readonly uuidToUlidMap = new Map<UUID, ULID>()
8391

84-
constructor(garden: GardenWithNewBackend, log: Log) {
92+
constructor(garden: GardenWithNewBackend, log: Log, shouldStreamLogEntries: boolean) {
8593
this.garden = garden
8694
this.log = log
95+
this.shouldStreamLogEntries = shouldStreamLogEntries
8796
}
8897

8998
convert<T extends CoreEventName>(name: T, payload: CoreEventPayload<T>): GrpcEventEnvelope[] {
@@ -132,18 +141,66 @@ export class GrpcEventConverter {
132141
payload: payload as CoreEventPayload<"aecAgentStatus">,
133142
})
134143
break
144+
// NOTE: We're not propagating "log" events, only keeping those for legacy Cloud
145+
case "logEntry":
146+
events = this.handleLogEntry({ context, payload: payload as CoreEventPayload<"logEntry"> })
147+
break
135148
default:
136-
// TODO: handle all event cases
137-
// name satisfies never // ensure all cases are handled
138-
this.log.silly(`GrpcEventStream: Unhandled core event ${name}`)
139149
return []
140150
}
141151

142-
if (events.length === 0) {
143-
this.log.silly(`GrpcEventStream: Ignoring core event ${name}`)
152+
return events
153+
}
154+
155+
private handleLogEntry({
156+
context,
157+
payload,
158+
}: {
159+
context: GardenEventContext
160+
payload: LogEntryEventPayload
161+
}): GrpcEventEnvelope[] {
162+
if (!this.shouldStreamLogEntries) {
163+
return []
144164
}
165+
const msg = resolveMsg(payload.message.msg)
166+
let rawMsg = resolveMsg(payload.message.rawMsg)
145167

146-
return events
168+
if (msg === rawMsg) {
169+
// No need to send both if they're the same
170+
rawMsg = undefined
171+
}
172+
173+
const coreLog = isCoreLogContext(payload.context) ? payload.context : undefined
174+
const actionLog = isActionLogContext(payload.context) ? payload.context : undefined
175+
176+
let actionUlid: ULID | undefined = undefined
177+
if (actionLog) {
178+
actionUlid = this.mapToUlid(actionLog.actionUid, "actionUid", "actionUlid")
179+
}
180+
181+
return [
182+
createGardenCliEvent(context, GardenCliEventType.LOGS_EMITTED, {
183+
case: "logsEmitted",
184+
value: create(GardenLogMessageEmittedSchema, {
185+
actionUlid,
186+
loggedAt: timestampFromDate(new Date(payload.timestamp)),
187+
logLevel: payload.level + 1,
188+
// NOTE: We deprecated the sectionName and logMessage fields, preferring the logDetails field instead
189+
originDescription: payload.context.origin,
190+
logDetails: {
191+
// Empty strings should be omitted
192+
msg: msg ?? undefined,
193+
rawMsg: rawMsg ?? undefined,
194+
data: payload.message.data ?? undefined,
195+
dataFormat: convertLogMessageDataFormat(payload.message.dataFormat),
196+
symbol: convertLogSymbol(payload.message.symbol),
197+
error: payload.message.error,
198+
coreLog,
199+
actionLog,
200+
},
201+
}),
202+
}),
203+
]
147204
}
148205

149206
private handleAecEnvironmentUpdate({
@@ -530,3 +587,38 @@ export function createGardenCliEvent(
530587
export function describeGrpcEvent(event: GrpcEventEnvelope): string {
531588
return `GrpcEvent(${event.eventUlid}, ${event.eventData.case}, ${event.eventData.value?.eventData.case})`
532589
}
590+
591+
function convertLogSymbol(symbol: LogSymbol | undefined): GrpcLogSymbol | undefined {
592+
switch (symbol) {
593+
case "info":
594+
return GrpcLogSymbol.INFO
595+
case "success":
596+
return GrpcLogSymbol.SUCCESS
597+
case "warning":
598+
return GrpcLogSymbol.WARNING
599+
case "error":
600+
return GrpcLogSymbol.ERROR
601+
case "empty":
602+
return GrpcLogSymbol.UNSPECIFIED
603+
default:
604+
return undefined
605+
}
606+
}
607+
608+
function convertLogMessageDataFormat(dataFormat: "json" | "yaml" | undefined): DataFormat | undefined {
609+
switch (dataFormat) {
610+
case "json":
611+
return DataFormat.JSON
612+
case "yaml":
613+
return DataFormat.YAML
614+
default:
615+
return undefined
616+
}
617+
}
618+
619+
function resolveMsg(msg: Msg | undefined): string | undefined {
620+
if (typeof msg === "function") {
621+
return msg()
622+
}
623+
return msg
624+
}

core/src/cloud/api/grpc-event-stream.ts

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@ import type { GardenWithNewBackend } from "../../garden.js"
1111
import { registerCleanupFunction, sleep } from "../../util/util.js"
1212
import type { Log } from "../../logger/log-entry.js"
1313
import type { EventName, EventPayload, GardenEventAnyListener } from "../../events/events.js"
14-
import { LogLevel } from "../../logger/logger.js"
15-
import type { LogEntryEventPayload } from "../api-legacy/restful-event-stream.js"
1614
import type {
1715
Event as GrpcEvent,
1816
GardenEventIngestionService,
@@ -71,7 +69,7 @@ export class GrpcEventStream {
7169
this.isClosed = false
7270
this.shouldStreamLogEntries = shouldStreamLogEntries
7371

74-
this.converter = new GrpcEventConverter(this.garden, this.log)
72+
this.converter = new GrpcEventConverter(this.garden, this.log, this.shouldStreamLogEntries)
7573

7674
// TODO: make sure it waits for the callback function completion
7775
registerCleanupFunction("grow-stream-session-cancelled-event", () => {
@@ -80,17 +78,19 @@ export class GrpcEventStream {
8078
}
8179

8280
this.handleEvent("sessionCancelled", {})
83-
this.close().catch(() => {})
81+
this.close().catch(() => {
82+
return
83+
})
8484
})
8585

86-
this.logListener = (name, payload) => {
87-
if (name === "logEntry" && payload.level <= LogLevel.debug) {
88-
this.handleLogEntry(payload)
89-
}
86+
// Handle log entries in the converter
87+
this.logListener = () => {
88+
return
9089
}
90+
9191
this.log.root.events.onAny(this.logListener)
9292

93-
this.eventListener = async (name, payload) => {
93+
this.eventListener = (name, payload) => {
9494
this.handleEvent(name, payload)
9595
}
9696
this.garden.events.onAny(this.eventListener)
@@ -167,7 +167,15 @@ export class GrpcEventStream {
167167
}
168168

169169
private handleEvent<T extends EventName>(name: T, payload: EventPayload<T>) {
170-
const events = this.converter.convert(name, payload)
170+
let events: GrpcEvent[] = []
171+
172+
try {
173+
events = this.converter.convert(name, payload)
174+
} catch (err) {
175+
this.log.warn(`GrpcEventStream: Error while converting event ${name}: ${err}`)
176+
return
177+
}
178+
171179
for (const event of events) {
172180
this.log.silly(
173181
() => `GrpcEventStream: ${this.outputStream ? "Sending" : "Buffering"} event ${describeGrpcEvent(event)}`
@@ -190,14 +198,6 @@ export class GrpcEventStream {
190198
}
191199
}
192200

193-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
194-
private handleLogEntry(logEntry: LogEntryEventPayload) {
195-
if (!this.shouldStreamLogEntries) {
196-
return
197-
}
198-
// TODO: logs handling
199-
}
200-
201201
private async streamEvents() {
202202
this.outputStream = createWritableIterable<IngestEventsRequest>()
203203

@@ -235,7 +235,7 @@ export class GrpcEventStream {
235235
)}`
236236
)
237237
} else {
238-
this.log.debug(() => `GrpcEventStream: Received ack for event ${nextAck.eventUlid}, final=${nextAck.final}`)
238+
this.log.silly(() => `GrpcEventStream: Received ack for event ${nextAck.eventUlid}, final=${nextAck.final}`)
239239
}
240240

241241
// Remove acknowledged event from the buffer
@@ -293,7 +293,7 @@ export class GrpcEventStream {
293293
return
294294
}
295295

296-
this.log.debug(() => `GrpcEventStream: Flushing ${this.eventBuffer.size} events from the buffer`)
296+
this.log.silly(() => `GrpcEventStream: Flushing ${this.eventBuffer.size} events from the buffer`)
297297

298298
// NOTE: The Map implementation in the javascript runtime guarantees that values will be iterated in the order they were added (FIFO).
299299
for (const event of this.eventBuffer.values()) {

core/src/commands/base.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,8 @@ export abstract class Command<
180180
noProject = false
181181
protected = false
182182
streamEvents = false // Set to true to stream events for the command
183-
streamLogEntries = false // Set to true to stream log entries for the command
183+
streamLogEntries = false // Set to true to stream log entries for the command to Garden Cloud v1 and v2
184+
streamLogEntriesV2 = false // Set to true to stream log entries for the command to just Garden Cloud v2
184185
isCustom = false // Used to identify custom commands
185186
isDevCommand = false // Set to true for internal commands in interactive command-line commands
186187
ignoreOptions = false // Completely ignore all option flags and pass all arguments directly to the command
@@ -344,11 +345,14 @@ export abstract class Command<
344345

345346
let result: CommandResult<R>
346347

348+
// We're streaming more logs to Garden Cloud v2 so we have a separate flag for that
349+
const shouldStreamLogs = this.streamLogEntries || (!!garden.cloudApi && this.streamLogEntriesV2)
350+
347351
const cloudEventStream = createCloudEventStream({
348352
sessionId: garden.sessionId,
349353
log,
350354
garden,
351-
opts: { shouldStreamEvents: this.streamEvents, shouldStreamLogs: this.streamLogEntries },
355+
opts: { shouldStreamEvents: this.streamEvents, shouldStreamLogs },
352356
})
353357
if (cloudEventStream) {
354358
log.silly(() => `Connecting Garden instance events to Cloud API`)

core/src/commands/build.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ export class BuildCommand extends Command<Args, Opts> {
5151

5252
override protected = true
5353
override streamEvents = true
54+
override streamLogEntriesV2 = true
5455

5556
override description = dedent`
5657
Runs all or specified Builds, taking into account build dependency order.

core/src/commands/custom.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ export class CustomCommandWrapper extends Command {
7373
help = ""
7474

7575
override isCustom = true
76-
76+
override streamLogEntriesV2 = true
7777
override allowUndefinedArguments = true
7878

7979
constructor(public spec: CommandResource) {

core/src/commands/delete.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export class DeleteEnvironmentCommand extends Command<{}, DeleteEnvironmentOpts>
6565

6666
override protected = true
6767
override streamEvents = true
68+
override streamLogEntriesV2 = true
6869

6970
override options = deleteEnvironmentOpts
7071

@@ -149,6 +150,7 @@ export class DeleteDeployCommand extends Command<DeleteDeployArgs, DeleteDeployO
149150
override protected = true
150151
workflows = true
151152
override streamEvents = true
153+
override streamLogEntriesV2 = true
152154

153155
override options = deleteDeployOpts
154156

0 commit comments

Comments
 (0)