Skip to content
Draft

WIP #107

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
24 changes: 16 additions & 8 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,36 @@
FROM denoland/deno:latest AS builder
WORKDIR /app

# Copy project files
COPY ./api /app/api
COPY ./tasks/vite.ts /app/tasks/vite.ts
# Copy lock
COPY ./deno.json /app/deno.json
COPY ./deno.lock /app/deno.lock
COPY ./web /app/web

# Cache dependencies
RUN deno cache --allow-scripts --lock=deno.lock api/server.ts tasks/vite.ts
# Install dependencies
RUN deno install

# Build frontend (dist/web) and compile backend with static files
RUN deno task prod
COPY ./tasks/vite.ts /app/tasks/vite.ts
COPY ./web /app/web
RUN deno cache --allow-scripts --lock=deno.lock tasks/vite.ts web/index.tsx
ENV BASE_URL="/"
RUN deno task prod:vite

# Build API
COPY ./api /app/api
COPY ./db /app/db
RUN deno cache --allow-scripts --lock=deno.lock api/server.ts
RUN deno task prod:api

# Stage 2: Final image
FROM debian:bookworm-slim
WORKDIR /app

# Copy compiled executable and Deno cache
COPY --from=builder /app/dist/api /app/server
COPY --from=builder /app/db/functions /app/db/functions

# Expose port from .env.prod (3021)
EXPOSE 3021

# Run the compiled executable
CMD ["/app/server", "--env=prod"]
CMD ["/app/server", "--env=prod"]
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,27 @@ deno task docker:prod
deno task docker:logs
```

### Docker Compose

Use the compose stack when you want ClickHouse and the app started together. It
has inline defaults, so no env file is required.

```bash
docker compose up --build
```

Override any value from your shell or a local `.env` file. Example:

```bash
PORT=8877 CLICKHOUSE_PASSWORD=strong-password docker compose up --build
```

The stack starts:

- `clickhouse`: database server on ports `8123` and `9000`
- `clickhouse-init`: one-shot schema creation for the `logs` table
- `app`: compiled application on port `3021`

### Available Tasks

```bash
Expand Down
50 changes: 41 additions & 9 deletions api/clickhouse-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
UNION,
} from '@01edu/api/validator'

const LogSchema = OBJ({
export const LogSchema = OBJ({
timestamp: NUM('The timestamp of the log event'),
trace_id: NUM('A float64 representation of the trace ID'),
span_id: optional(NUM('A float64 representation of the span ID')),
Expand All @@ -41,15 +41,15 @@ export const LogSchemaOutput = OBJ({
service_instance_id: optional(STR('Service instance ID')),
}, 'A log event')

const LogsInputSchema = UNION(
export const LogsInputSchema = UNION(
LogSchema,
ARR(LogSchema, 'An array of log events'),
)

type Log = Asserted<typeof LogSchemaOutput>
type LogsInput = Asserted<typeof LogsInputSchema>

const client = createClient({
export const client = createClient({
url: CLICKHOUSE_HOST,
username: CLICKHOUSE_USER,
password: CLICKHOUSE_PASSWORD,
Expand Down Expand Up @@ -79,10 +79,7 @@ const numberToHex128 = (() => {
}
})()

async function insertLogs(
service_name: string,
data: LogsInput,
) {
export async function insertLogs(service_name: string, data: LogsInput) {
const logsToInsert = Array.isArray(data) ? data : [data]
if (logsToInsert.length === 0) throw respond.NoContent()

Expand Down Expand Up @@ -202,7 +199,7 @@ function inferParamType(key: string, value: string): string {
return 'String'
}

async function getLogs(dep: string, data: FetchTablesParams) {
export async function getLogs(dep: string, data: FetchTablesParams) {
const { query, params } = buildLogsQuery(dep, data)
try {
const rs = await client.query({
Expand Down Expand Up @@ -240,4 +237,39 @@ async function getLogs(dep: string, data: FetchTablesParams) {
// }
// }

export { client, getLogs, insertLogs, LogSchema, LogsInputSchema }
export const initLogTable = async () => {
await client.ping()
await client.command({
query: `
CREATE TABLE IF NOT EXISTS logs (
id UUID DEFAULT generateUUIDv4(),
-- Flattened resource fields
service_name LowCardinality(String),
service_version LowCardinality(String),
service_instance_id String,

timestamp DateTime64(3, 'UTC') DEFAULT now64(3, 'UTC'),
observed_timestamp DateTime64(3, 'UTC') DEFAULT now64(3, 'UTC'),
trace_id FixedString(16),
span_id FixedString(16),
severity_number UInt8,
-- derived column, computed by DB from severity_number
severity_text LowCardinality(String) MATERIALIZED CASE
WHEN severity_number > 4 AND severity_number <= 8 THEN 'DEBUG'
WHEN severity_number > 8 AND severity_number <= 12 THEN 'INFO'
WHEN severity_number > 12 AND severity_number <= 16 THEN 'WARN'
WHEN severity_number > 20 AND severity_number <= 24 THEN 'FATAL'
ELSE 'ERROR'
END,
-- Often empty, but kept for OTEL spec compliance
body Nullable(String),
attributes JSON,
event_name LowCardinality(String)
)
ENGINE = MergeTree
PARTITION BY toYYYYMMDD(timestamp)
ORDER BY (service_name, timestamp, trace_id)
SETTINGS index_granularity = 8192, min_bytes_for_wide_part = 0;
`,
})
}
13 changes: 8 additions & 5 deletions api/lib/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { ENV } from '@01edu/api/env'

export const PORT = Number(ENV('PORT', '2119'))
export const PICTURE_DIR = ENV('PICTURE_DIR', './.picture')
export const GOOGLE_CLIENT_ID = ENV('GOOGLE_CLIENT_ID')
export const CLIENT_SECRET = ENV('CLIENT_SECRET')
export const REDIRECT_URI = ENV('REDIRECT_URI')
export const GOOGLE_CLIENT_ID = ENV('GOOGLE_CLIENT_ID', '')
export const CLIENT_SECRET = ENV('CLIENT_SECRET', '')
export const REDIRECT_URI = ENV('REDIRECT_URI', `http://localhost:${PORT}`)
export const ORIGIN = new URL(REDIRECT_URI).origin
export const SECRET = ENV(
'SECRET',
Expand All @@ -21,5 +21,8 @@ export const DB_SCHEMA_REFRESH_MS = Number(
ENV('DB_SCHEMA_REFRESH_MS', `${24 * 60 * 60 * 1000}`),
)

export const STORE_URL = ENV('STORE_URL')
export const STORE_SECRET = ENV('STORE_SECRET')
export const STORE_URL = ENV('STORE_URL', '')
export const STORE_SECRET = ENV('STORE_SECRET', '')
const LOCAL_ENV = ENV('LOCAL_ENV', '')
export const isLocal = LOCAL_ENV === 'yes' || LOCAL_ENV === '1' ||
LOCAL_ENV === 'true'
46 changes: 8 additions & 38 deletions api/lib/functions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { batch } from '/api/lib/json_store.ts'
import { join } from '@std/path'
import { join, toFileUrl } from '@std/path'
import { ensureDir } from '@std/fs'

// Define the function signatures
Expand Down Expand Up @@ -35,13 +35,14 @@ export type LoadedFunction = {

// Map<projectSlug, List of loaded functions>
const functionsMap = new Map<string, LoadedFunction[]>()
let watcher: Deno.FsWatcher | null = null
const functionsDir = './db/functions'
const functionsDir = join(import.meta.dirname!, '../../db/functions')
const functionsDirUrl = toFileUrl(
functionsDir.endsWith('/') ? functionsDir : `${functionsDir}/`,
)

export async function init() {
await ensureDir(functionsDir)
await loadAll()
startWatcher()
}

async function loadAll() {
Expand All @@ -55,18 +56,15 @@ async function loadAll() {

async function reloadProjectFunctions(slug: string) {
const projectDir = join(functionsDir, slug)
const projectDirUrl = new URL(`${slug}/`, functionsDirUrl)
const loaded: LoadedFunction[] = []

try {
await batch(5, Deno.readDir(projectDir), async (entry) => {
if (entry.isFile && entry.name.endsWith('.js')) {
const mainFile = join(projectDir, entry.name)
// Build a fresh import URL to bust cache
const importUrl = `file://${await Deno.realPath(
mainFile,
)}?t=${Date.now()}`
const mainFileUrl = new URL(entry.name, projectDirUrl)
try {
const module = await import(importUrl)
const module = await import(`${mainFileUrl.href}?t=${Date.now()}`)
// We expect a default export or specific named exports
const fns = module.default
if (fns && typeof fns === 'object') {
Expand Down Expand Up @@ -95,40 +93,12 @@ async function reloadProjectFunctions(slug: string) {
}
}

function startWatcher() {
if (watcher) return
console.info(`Starting function watcher on ${functionsDir}`)
watcher = Deno.watchFs(functionsDir, { recursive: true }) // Process events
;(async () => {
for await (const event of watcher!) {
if (!['modify', 'create', 'remove', 'rename'].includes(event.kind)) {
continue
}
for (const path of event.paths) {
if (!path.endsWith('.js')) continue
const parts = path.split('/')
const fileName = parts.pop()
const slug = parts.pop()
if (!fileName || !slug) continue
await reloadProjectFunctions(slug)
}
}
})()
}

export function getProjectFunctions(
slug: string,
): LoadedFunction[] | undefined {
return functionsMap.get(slug)
}

export function stopWatcher() {
if (watcher) {
watcher.close()
watcher = null
}
}

export async function applyReadTransformers<T>(
data: T,
projectId: string,
Expand Down
1 change: 0 additions & 1 deletion api/lib/functions_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,5 +105,4 @@ Deno.test('Functions Module - Pipeline & Config', async () => {
// Skipped
}
await new Promise((r) => setTimeout(r, 500))
functions.stopWatcher()
})
Loading