Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 1 addition & 9 deletions packages/console/core/src/actor.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { Context } from "./context"
import { UserRole } from "./schema/user.sql"
import { Log } from "./util/log"

export namespace Actor {
interface Account {
Expand Down Expand Up @@ -38,8 +37,6 @@ export namespace Actor {
const ctx = Context.create<Info>()
export const use = ctx.use

const log = Log.create().tag("namespace", "actor")

export function provide<R, T extends Info["type"]>(
type: T,
properties: Extract<Info, { type: T }>["properties"],
Expand All @@ -50,12 +47,7 @@ export namespace Actor {
type,
properties,
} as any,
() => {
return Log.provide({ ...properties }, () => {
log.info("provided")
return cb()
})
},
cb,
)
}

Expand Down
55 changes: 0 additions & 55 deletions packages/console/core/src/util/log.ts

This file was deleted.

174 changes: 137 additions & 37 deletions packages/core/src/effect/logger.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,79 @@
import { Cause, Effect, Logger, References } from "effect"
import * as Log from "../util/log"
import { appendFileSync } from "fs"
import fs from "fs/promises"
import path from "path"
import { Cause, Effect, Layer, Logger, References, Schema } from "effect"
import * as Global from "../global"
import { ensureProcessMetadata } from "../util/opencode-process"

type Fields = Record<string, unknown>

const normalizeKey = (key: string) => (key === "sessionID" ? "session.id" : key)
export const Level = Schema.Literals(["DEBUG", "INFO", "WARN", "ERROR"]).annotate({
identifier: "LogLevel",
description: "Log level",
})
export type Level = Schema.Schema.Type<typeof Level>

export interface Handle {
readonly debug: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly info: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly warn: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly error: (msg?: unknown, extra?: Fields) => Effect.Effect<void>
readonly with: (extra: Fields) => Handle
const levelPriority: Record<Level, number> = {
DEBUG: 0,
INFO: 1,
WARN: 2,
ERROR: 3,
}

const clean = (input?: Fields): Fields =>
const normalizeKey = (key: string) => (key === "sessionID" ? "session.id" : key)

const clean = (input?: object): Fields =>
Object.fromEntries(
Object.entries(input ?? {})
.filter((entry) => entry[1] !== undefined && entry[1] !== null)
.map(([key, value]) => [normalizeKey(key), value]),
)

const text = (input: unknown): string => {
// oxlint-disable-next-line no-base-to-string
if (Array.isArray(input)) return input.map((item) => String(item)).join(" ")
// oxlint-disable-next-line no-base-to-string
return input === undefined ? "" : String(input)
export function file() {
if (disabled(process.env.OPENCODE_LOG_FILE)) return ""
return process.env.OPENCODE_LOG_FILE || path.join(Global.Path.log, "log.jsonl")
}

function shouldLog(input: Level): boolean {
return levelPriority[input] >= levelPriority[parseLevel(process.env.OPENCODE_LOG_LEVEL) ?? "INFO"]
}

function write(input: { json: string; pretty: string }) {
const target = file()
if (target) {
try {
appendFileSync(target, input.json)
} catch {}
}
if (truthy(process.env.OPENCODE_PRINT_LOGS)) process.stderr.write(input.pretty)
}

const call = (run: (msg?: unknown) => Effect.Effect<void>, base: Fields, msg?: unknown, extra?: Fields) => {
const ann = clean({ ...base, ...extra })
const fx = run(msg)
return Object.keys(ann).length ? Effect.annotateLogs(fx, ann) : fx
function build(inputLevel: Level, ts: Date, message: unknown, fields: Fields): { json: string; pretty: string } {
const metadata = ensureProcessMetadata("main")
const service = typeof fields.service === "string" ? fields.service : undefined
if (service) delete fields.service
const text = stringifyMessage(message)
const record = {
ts: ts.toISOString(),
level: inputLevel,
message: text,
run_id: metadata.runID,
process_role: metadata.processRole,
pid: process.pid,
service,
fields: Object.fromEntries(Object.entries(fields).map(([key, value]) => [key, normalize(value)])),
}
const prefix = Object.entries({ service, ...record.fields })
.filter((entry) => entry[1] !== undefined && entry[1] !== null)
.map(([key, value]) => `${key}=${typeof value === "object" ? safeStringify(value) : value}`)
.join(" ")
return {
json: safeStringify(record) + "\n",
pretty: [inputLevel.padEnd(5), ts.toISOString().split(".")[0], prefix, text].filter(Boolean).join(" ") + "\n",
}
}

export const logger = Logger.make((opts) => {
const logger = Logger.make((opts) => {
const extra = clean(opts.fiber.getRef(References.CurrentLogAnnotations))
const now = opts.date.getTime()
for (const [key, start] of opts.fiber.getRef(References.CurrentLogSpans)) {
Expand All @@ -43,31 +83,91 @@ export const logger = Logger.make((opts) => {
extra.cause = Cause.pretty(opts.cause)
}

const svc = typeof extra.service === "string" ? extra.service : undefined
if (svc) delete extra.service
const log = svc ? Log.create({ service: svc }) : Log.Default
const msg = text(opts.message)

switch (opts.logLevel) {
case "Trace":
case "Debug":
return log.debug(msg, extra)
if (shouldLog("DEBUG")) write(build("DEBUG", opts.date, opts.message, extra))
return
case "Warn":
return log.warn(msg, extra)
if (shouldLog("WARN")) write(build("WARN", opts.date, opts.message, extra))
return
case "Error":
case "Fatal":
return log.error(msg, extra)
if (shouldLog("ERROR")) write(build("ERROR", opts.date, opts.message, extra))
return
default:
return log.info(msg, extra)
if (shouldLog("INFO")) write(build("INFO", opts.date, opts.message, extra))
}
})

export const layer = Logger.layer([logger], { mergeWithExisting: false })
type LoggerInput = Logger.Logger<unknown, unknown> | Effect.Effect<Logger.Logger<unknown, unknown>, unknown, unknown>

export const create = (base: Fields = {}): Handle => ({
debug: (msg, extra) => call((item) => Effect.logDebug(item), base, msg, extra),
info: (msg, extra) => call((item) => Effect.logInfo(item), base, msg, extra),
warn: (msg, extra) => call((item) => Effect.logWarning(item), base, msg, extra),
error: (msg, extra) => call((item) => Effect.logError(item), base, msg, extra),
with: (extra) => create({ ...base, ...extra }),
})
const makeLayer = <const Loggers extends ReadonlyArray<LoggerInput>>(loggers: Loggers) =>
Logger.layer(loggers, { mergeWithExisting: false }).pipe(
Layer.tap(() =>
Effect.promise(async () => {
const target = file()
if (target) await fs.mkdir(path.dirname(target), { recursive: true })
}),
),
)

export const layer = makeLayer([logger])

export const layerWith = <const Loggers extends ReadonlyArray<LoggerInput>>(loggers: Loggers) =>
makeLayer([logger, ...loggers] as const)

function truthy(value: string | undefined) {
return value?.toLowerCase() === "1" || value?.toLowerCase() === "true"
}

function disabled(value: string | undefined) {
const lower = value?.toLowerCase()
return lower === "0" || lower === "false" || lower === "off"
}

function parseLevel(value: string | undefined): Level | undefined {
if (value === "DEBUG" || value === "INFO" || value === "WARN" || value === "ERROR") return value
return undefined
}

function formatError(error: Error, depth = 0): string {
const result = error.message
return error.cause instanceof Error && depth < 10
? result + " Caused by: " + formatError(error.cause, depth + 1)
: result
}

function stringifyMessage(message: unknown): string {
if (message instanceof Error) return formatError(message)
if (message === undefined) return ""
if (typeof message === "string") return message
if (Array.isArray(message)) return message.map((item) => stringifyMessage(item)).join(" ")
if (typeof message === "object") return safeStringify(message)
return String(message)
}

function normalize(value: unknown): unknown {
if (value instanceof Error) {
return {
name: value.name,
message: formatError(value),
stack: value.stack,
}
}
if (typeof value === "bigint") return value.toString()
return value
}

function safeStringify(value: unknown) {
const seen = new WeakSet<object>()
return JSON.stringify(value, (_, item) => {
if (typeof item === "bigint") return item.toString()
if (item instanceof Error) return normalize(item)
if (typeof item === "object" && item !== null) {
if (seen.has(item)) return "[Circular]"
seen.add(item)
}
return item
})
}
20 changes: 8 additions & 12 deletions packages/core/src/effect/observability.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Effect, Layer, Logger } from "effect"
import { Effect, Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { OtlpLogger, OtlpSerialization } from "effect/unstable/observability"
import * as EffectLogger from "./logger"
Expand Down Expand Up @@ -54,17 +54,13 @@ export function resource(): { serviceName: string; serviceVersion: string; attri
}

function logs() {
return Logger.layer(
[
EffectLogger.logger,
OtlpLogger.make({
url: `${base}/v1/logs`,
resource: resource(),
headers,
}),
],
{ mergeWithExisting: false },
).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer))
return EffectLogger.layerWith([
OtlpLogger.make({
url: `${base}/v1/logs`,
resource: resource(),
headers,
}),
]).pipe(Layer.provide(OtlpSerialization.layerJson), Layer.provide(FetchHttpClient.layer))
}

const traces = async () => {
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const paths = {
},
data,
bin: path.join(cache, "bin"),
log: path.join(data, "log"),
log: state,
repos: path.join(data, "repos"),
cache,
config,
Expand Down
Loading
Loading