This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Traceway is an error tracking and monitoring platform consisting of:
- Frontend: SvelteKit 2 dashboard application with Svelte 5
- Backend: Go/Gin API server with ClickHouse database
- Go Client SDK: Distributed tracing SDK for Go applications (external repo)
- No pointless comments: Do not add comments that simply describe what the code does. The code should be self-explanatory. Only add comments when explaining non-obvious "why" decisions.
- No
py-4in dialog form content: Do not addpy-4on the content wrapper insideAlertDialogorDialogcomponents — it creates too much blank space between the form and the action buttons. - Dialog button labels & toasts: For form dialogs, use descriptive button labels with icons instead of generic "Create"/"Update". Create actions:
<Plus icon> {Action} {Entity}(e.g., "+ New Widget Group"). Update actions:<Check icon> Update {Entity}. After successful create/update, show atoast.success('Successfully {action} the {Entity}', { position: 'top-center' }). The button should only bedisabledduring the loading state — never disable it to enforce validation; let the backend return 422 and show the error in the dialog instead.
| Component | Command | Description |
|---|---|---|
| Frontend | cd frontend && npm run dev |
Dev server (port 5173) |
| Frontend | npm run build |
Production build |
| Frontend | npm run check |
TypeScript checking |
| Backend | cd backend && go run . |
API server (port 8082) |
| Go Client | External repo at /Users/dusanstanojevic/Documents/workspace/go-client |
Build with go build ./... |
- Frontend: SvelteKit 2.49, Svelte 5.45, Tailwind CSS v4, shadcn-svelte, Vite 7
- Backend: Go 1.25, Gin 1.11, ClickHouse, PostgreSQL
- Client SDK: Go 1.25, Gin middleware support
- Import:
github.com/tracewayapp/go-lightning/lit - Purpose: Lightweight generic CRUD operations for PostgreSQL
All models are registered centrally in models/models.go via models.Init():
func Init() {
lit.RegisterModel[User](lit.PostgreSQL)
lit.RegisterModel[Project](lit.PostgreSQL)
// ...all models registered here
}Repository-local result models (e.g., aggregate structs only used in one repo) can use file-level init() instead.
- Fields: CamelCase → snake_case (
FirstName→first_name) - Consecutive uppercase: stay together (
HTTPCode→http_code) - Tables: pluralize + snake_case (
User→users) - Override via struct tag:
lit:"custom_name"
All lit functions take *sql.Tx as the first argument for transactional consistency:
| Function | Description |
|---|---|
lit.Insert[T](tx, &entity) |
Insert, returns auto-generated int ID |
lit.InsertUuid[T](tx, &entity) |
Insert with auto-generated UUID |
lit.InsertExistingUuid[T](tx, &entity) |
Insert with pre-set UUID |
lit.Select[T](tx, query, args...) |
Retrieve multiple records (returns []*T) |
lit.SelectSingle[T](tx, query, args...) |
Retrieve one record (returns *T) |
lit.Update[T](tx, &entity, "id = $1", id) |
Update (auto-prepends WHERE) |
lit.UpdateNative(tx, "UPDATE table SET col = $1 WHERE ...", args...) |
Raw SQL update for partial/single-field changes |
lit.Delete(tx, "DELETE FROM table WHERE id = $1", id) |
Delete records |
All PostgreSQL operations should use ExecuteTransaction for automatic commit/rollback:
// ExecuteTransaction[T] wraps a function in a transaction
// - Commits on success, rolls back on error or panic
// - Returns (T, error) directly - no pointer wrapping
project, err := pgdb.ExecuteTransaction(func(tx *sql.Tx) (*models.Project, error) {
// All repository calls receive the transaction
return repositories.ProjectRepository.FindById(tx, id)
})For auth flows and routes requiring transaction context throughout the request lifecycle, use the Transactional middleware:
// In routes.go - wrap routes that need transaction context
api.POST("/register", middleware.Transactional, authController.Register)
api.POST("/login", middleware.Transactional, authController.Login)
// In controller - retrieve transaction from Gin context
func (c *AuthController) Register(ctx *gin.Context) {
tx := middleware.GetTx(ctx) // Get transaction from context
// Use tx for all repository calls
user, err := repositories.UserRepository.FindByEmail(tx, email)
if err != nil {
ctx.JSON(500, gin.H{"error": err.Error()})
return // Transaction auto-rolls back on non-success status
}
ctx.JSON(201, user) // Transaction auto-commits on 200/201/303
}Auto-commit/rollback behavior:
- Commits on status codes: 200, 201, 303
- Rolls back on all other status codes or panics
Preference: For CRUD controller methods, always prefer using middleware.Transactional in the route + middleware.GetTx(ctx) in the controller over pgdb.ExecuteTransaction. The middleware approach keeps controllers flat, avoids nested closures, and follows the established pattern.
Repositories accept *sql.Tx to participate in transactions:
func (p *projectRepository) FindById(tx *sql.Tx, id uuid.UUID) (*models.Project, error) {
return lit.SelectSingle[models.Project](
tx,
"SELECT id, name, token, framework, created_at FROM projects WHERE id = $1",
id,
)
}- Uses
$1, $2, $3placeholders (not?) - Tables must have an
idcolumn - Always pass
*sql.TxfromExecuteTransactionto lit functions
Always initialize all struct fields with defaults:
When using lit.Insert, all struct fields are included in the INSERT statement, overriding database defaults. Always set fields like CreatedAt explicitly:
// CORRECT - set CreatedAt explicitly
user := &models.User{
Email: email,
Name: name,
CreatedAt: time.Now().UTC(),
}
// WRONG - CreatedAt remains zero value (0001-01-01)
user := &models.User{
Email: email,
Name: name,
}lit.Update WHERE clause:
The lit.Update function automatically includes WHERE in the generated SQL. Do not add WHERE yourself:
// CORRECT - just the condition
lit.Update(tx, &user, "id = $1", user.Id)
// WRONG - results in "WHERE WHERE id = $1"
lit.Update(tx, &user, "WHERE id = $1", user.Id)For queries that return aggregated or computed values (not direct table rows), create a custom result model:
// Define a result model for the query output
type CountResult struct {
Count int `lit:"count"`
}
// Register in models.Init() (or file-level init() if repo-local)
lit.RegisterModel[CountResult](lit.PostgreSQL)
// Use in repository
func (r *userRepository) CountByOrganization(tx *sql.Tx, orgID uuid.UUID) (int, error) {
result, err := lit.SelectSingle[CountResult](
tx,
"SELECT COUNT(*) as count FROM users WHERE organization_id = $1",
orgID,
)
if err != nil {
return 0, err
}
if result == nil {
return 0, nil
}
return result.Count, nil
}IMPORTANT: When using lit/PostgreSQL, do NOT check for sql.ErrNoRows. The lit library returns nil when no record is found, not an error. Always check for nil instead:
// CORRECT - check for nil
user, err := repositories.UserRepository.FindByEmail(tx, email)
if err != nil {
return nil, err // actual database error
}
if user == nil {
// record not found - handle accordingly
return nil, errors.New("user not found")
}
// WRONG - do not use sql.ErrNoRows with lit
user, err := repositories.UserRepository.FindByEmail(tx, email)
if err == sql.ErrNoRows { // This won't work with lit!
// ...
}IMPORTANT: ClickHouse queries behave differently from lit/PostgreSQL. ClickHouse returns sql.ErrNoRows when no record is found. Always use errors.Is() to check:
// CORRECT - ClickHouse returns sql.ErrNoRows
exception, err := exceptionRepo.GetByHash(projectID, hash)
if errors.Is(err, sql.ErrNoRows) {
// record not found - handle accordingly
return nil, errors.New("exception not found")
}
if err != nil {
return nil, err // actual database error
}
// Summary of error handling:
// - lit/PostgreSQL: check `if result == nil` (no error returned)
// - ClickHouse: check `errors.Is(err, sql.ErrNoRows)`JWT_SECRET=<min 32 char secret for JWT signing>
CLICKHOUSE_SERVER=localhost:9000
CLICKHOUSE_DATABASE=traceway
CLICKHOUSE_USERNAME=default
CLICKHOUSE_PASSWORD=
CLICKHOUSE_TLS=false
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_DATABASE=traceway
POSTGRES_USERNAME=traceway
POSTGRES_PASSWORD=
POSTGRES_SSLMODE=disable
Go Application → [traceway SDK] → GZIP POST /api/report → Backend → ClickHouse
↓
Dashboard ← [SvelteKit Frontend] ← JSON API ← Gin Controllers
Two-tier system:
- Client Auth: Project bearer tokens (SDK telemetry via
Authorization: Bearer <project_token>) - App Auth: JWT-based user authentication (dashboard via
Authorization: Bearer <jwt_token>)
- Framework: SvelteKit 2 with Svelte 5 runes API
- Styling: Tailwind CSS v4 with shadcn-svelte components
- Build: Vite 7, static adapter with SPA fallback
- SSR: Disabled - pure client-side SPA (
ssr = falsein+layout.ts)
frontend/
├── src/
│ ├── routes/ # SvelteKit pages
│ ├── lib/
│ │ ├── api.ts # API client with auth
│ │ ├── state/ # Svelte 5 state management
│ │ ├── components/
│ │ │ ├── ui/ # shadcn-svelte base components
│ │ │ └── traceway/ # Custom Traceway components
│ │ └── utils/ # Helpers (formatting, sorting)
│ └── app.css # Tailwind + global styles
├── static/ # Static assets
└── svelte.config.js # SvelteKit config
// Use $state() for reactive state
let data = $state<Type>(initial)
// Use $derived() for computed values
let computed = $derived(expression)
// Use $effect() for side effects
$effect(() => { /* reactive code */ })| File | Purpose | Persistence |
|---|---|---|
src/lib/state/auth.svelte.ts |
Token auth, login/logout | localStorage |
src/lib/state/projects.svelte.ts |
Multi-project management | localStorage |
src/lib/state/theme.svelte.ts |
Dark/light mode toggle | localStorage |
src/lib/state/timezone.svelte.ts |
UTC/local timezone toggle | localStorage |
State files export class instances as singletons:
// src/lib/state/auth.svelte.ts
class AuthState {
token = $state<string | null>(null)
isAuthenticated = $derived(!!this.token)
constructor() {
// Load from localStorage on init
this.token = localStorage.getItem('token')
}
}
export const authState = new AuthState()The API client automatically:
- Includes
Authorization: Bearer <token>header - Adds
projectIdas a query parameter to all requests - Handles 401 responses by logging out and redirecting to
/login
// Usage
const data = await api.post<ResponseType>('/endpoint', { body })
const data = await api.get<ResponseType>('/endpoint')Tables use shadcn-svelte base components with custom Traceway wrappers:
| Component | Location | Purpose |
|---|---|---|
Table, TableHeader, etc. |
src/lib/components/ui/table/ |
Base shadcn components |
TracewayTableHeader |
src/lib/components/traceway/traceway-table-header.svelte |
Adds sorting + tooltips |
TableEmptyState |
src/lib/components/traceway/table-empty-state.svelte |
Empty state display |
PaginationFooter |
src/lib/components/traceway/pagination-footer.svelte |
Pagination controls |
Table sorting persists to localStorage using a consistent pattern via src/lib/utils/sort-storage.ts:
// Types
type SortState = { field: string; direction: 'asc' | 'desc' }
// Key format: traceway_sort_{pageKey}
// Example: traceway_sort_issues, traceway_sort_endpoints
// In +page.svelte - load initial state
let sortState = $state<SortState>(getSortState('issues', { field: 'last_seen', direction: 'desc' }))
// After sort change
function onSortClick(field: string) {
sortState = handleSortClick(field, sortState.field, sortState.direction, 'desc')
setSortState('issues', sortState) // Persist to localStorage
}Available functions (src/lib/utils/sort-storage.ts):
| Function | Description |
|---|---|
getSortState(pageKey, defaultState) |
Load sort state from localStorage |
setSortState(pageKey, state) |
Save sort state to localStorage |
handleSortClick(field, currentField, currentDirection, defaultDirection) |
Toggle sort direction, returns new SortState |
<TracewayTableHeader
label="Last Seen"
column="last_seen"
tooltip="When this issue was last reported"
{orderBy}
onclick={() => handleSortClick('last_seen')}
/>Time range and filters persist in URL query params via src/lib/utils/url-params.ts:
// Available presets: 30m, 60m, 3h, 6h, 12h, 24h, 3d, 7d, 1M, 3M
// Parse time range from URL (in +page.svelte)
const timeRange = parseTimeRangeFromUrl(timezoneState.timezone, '24h')
// Get resolved Date objects for API calls
const { from, to } = getResolvedTimeRange(timeRange, timezoneState.timezone)
// Update URL with new time range (preserves other params)
updateUrl({ preset: '7d' })
updateUrl({ from: customFrom, to: customTo }) // Custom rangeAvailable functions (src/lib/utils/url-params.ts):
| Function | Description |
|---|---|
parseTimeRangeFromUrl(timezone, defaultPreset) |
Parse TimeRangeParams from current URL |
getResolvedTimeRange(params, timezone) |
Convert params to { from: Date, to: Date } |
updateUrl(params, options?) |
Update URL query params, optionally replace history |
Helper functions for preserving URL params during navigation (src/lib/utils/navigation.ts):
// Add sticky params (like time range) to href for <a> tags
const href = addStickyParamsToHref('/issues/abc123', 'preset', 'from', 'to')
// Result: "/issues/abc123?preset=24h" (if preset=24h is in current URL)
// Create click handler for table rows that preserves params
const handleClick = createRowClickHandler('/issues/abc123', 'preset', 'from', 'to')Available functions:
| Function | Description |
|---|---|
addStickyParamsToHref(href, ...stickyParams) |
Returns href with specified params from current URL |
createRowClickHandler(href, ...stickyParams) |
Returns click handler that navigates with sticky params |
/ Dashboard (protected) - overview metrics
/login Login page (public)
/register Registration page (public)
/issues Issues list with filtering/sorting
/issues/[hash] Exception details view
/issues/[hash]/events Exception events timeline
/endpoints Endpoint analytics with P50/P95/P99
/endpoints/[endpoint] Single endpoint details
/tasks Background tasks list
/tasks/[task] Single task details
/metrics System metrics dashboard (CPU, memory, etc.)
/connection SDK integration guide
Location: src/lib/components/ui/*
Uses shadcn-svelte registry with bits-ui primitives. Key components:
button,card,table,badge,tooltipselect,input,checkboxsheet(slide-out panels),dialog(modals)
- Framework: Gin Gonic HTTP framework
- Database: ClickHouse (columnar OLAP for telemetry), PostgreSQL (relational for projects)
- Port: 8082
- Pattern: Repository pattern with singleton controllers
backend/
├── main.go # Entry point, DB init, server start
├── app/
│ ├── controllers/
│ │ ├── routes.go # Route registration
│ │ ├── dashboard.go # Dashboard metrics
│ │ ├── auth.go # Login handler
│ │ ├── projects.go # Project CRUD
│ │ └── clientcontrollers/
│ │ └── report.go # Telemetry ingestion (/api/report)
│ ├── repositories/ # ClickHouse queries
│ │ ├── transactions.go
│ │ ├── exceptions.go
│ │ ├── metrics.go
│ │ └── projects.go
│ ├── models/ # Data structures
│ ├── middleware/
│ │ ├── auth.go # Token validation
│ │ └── gzip.go # Request decompression
│ ├── cache/ # In-memory project token cache
│ ├── pgdb/ # PostgreSQL connection manager
│ └── migrations/
│ ├── ch/ # ClickHouse migrations
│ └── pg/ # PostgreSQL migrations
| Route Type | Middleware Chain |
|---|---|
| Read-only telemetry | UseAppAuth, RequireProjectAccess |
| Write telemetry | UseAppAuth, RequireProjectAccess, RequireWriteAccess |
| PostgreSQL CRUD (read) | UseAppAuth, RequireProjectAccess, Transactional |
| PostgreSQL CRUD (write) | UseAppAuth, RequireProjectAccess, RequireWriteAccess, Transactional |
| Admin org management | UseAppAuth, RequireAdminAccess, Transactional |
| Public (auth/invitations) | Transactional only |
| Client SDK ingestion | CORSReport, UseClientAuth, UseGzip |
Client SDK Ingestion
| Method | Endpoint | Auth | Purpose |
|---|---|---|---|
| POST | /api/report |
Client | Telemetry ingestion (gzipped) |
| POST | /api/otel/v1/traces |
Client | OTLP/HTTP trace ingestion |
| POST | /api/otel/v1/metrics |
Client | OTLP/HTTP metric ingestion |
| POST | /api/otel/v1/logs |
Client | OTLP/HTTP log ingestion |
Auth & Registration
| Method | Endpoint | Auth | Purpose |
|---|---|---|---|
| POST | /api/login |
None | Dashboard authentication |
| POST | /api/register |
None | New user registration |
| GET | /api/has-organizations |
None | Check if any orgs exist (self-hosted only) |
| POST | /api/forgot-password |
None | Request password reset email |
| GET | /api/password-reset/:token |
None | Validate reset token |
| POST | /api/password-reset/:token |
None | Reset password with token |
Projects
| Method | Endpoint | Auth | Purpose |
|---|---|---|---|
| GET | /api/projects |
App | List projects |
| POST | /api/projects |
App+Write | Create project |
| POST | /api/projects/source-map-token |
App+Write | Generate source map upload token |
Dashboard
| Method | Endpoint | Auth | Purpose |
|---|---|---|---|
| GET | /api/dashboard |
App | Dashboard metrics |
| GET | /api/dashboard/overview |
App | Recent issues + top endpoints |
| POST | /api/stats |
App | Homepage stats |
Metrics
| Method | Endpoint | Auth | Purpose |
|---|---|---|---|
| GET | /api/metrics/application |
App | Application metrics |
| GET | /api/metrics/stats |
App | Stats metrics |
| GET | /api/metrics/server |
App | Server metrics |
| POST | /api/metrics/query |
App | Custom metric queries |
| GET | /api/metrics/discover |
App | Discover available metrics |
| GET | /api/metrics/discover/tags |
App | Discover metric tags |
| PUT | /api/metrics/registry |
App+Write | Update metric registry entry |
Widget Groups & Widgets
| Method | Endpoint | Auth | Purpose |
|---|---|---|---|
| GET | /api/widget-groups |
App | List widget groups |
| POST | /api/widget-groups |
App+Write | Create widget group |
| GET | /api/widget-groups/:id |
App | Get group with widgets |
| PUT | /api/widget-groups/:id |
App+Write | Update widget group |
| DELETE | /api/widget-groups/:id |
App+Write | Delete widget group |
| POST | /api/widget-groups/:id/widgets |
App+Write | Add widget |
| PUT | /api/widget-groups/:id/widgets/:wid |
App+Write | Update widget |
| PUT | /api/widget-groups/:id/widgets/:wid/move |
App+Write | Reorder widget |
| DELETE | /api/widget-groups/:id/widgets/:wid |
App+Write | Delete widget |
Endpoints
| Method | Endpoint | Auth | Purpose |
|---|---|---|---|
| POST | /api/endpoints |
App | List all endpoints |
| POST | /api/endpoints/grouped |
App | Endpoint aggregates (P50/P95/P99) |
| POST | /api/endpoints/endpoint |
App | Single endpoint details |
| POST | /api/endpoints/chart |
App | Stacked chart data |
| GET | /api/endpoints/slow |
App | Get slow endpoint threshold |
| POST | /api/endpoints/slow |
App+Write | Set slow endpoint threshold |
| POST | /api/endpoints/:endpointId |
App | Endpoint detail view |
Tasks
| Method | Endpoint | Auth | Purpose |
|---|---|---|---|
| POST | /api/tasks |
App | List all tasks |
| POST | /api/tasks/grouped |
App | Grouped by task name |
| POST | /api/tasks/task |
App | Single task details |
| POST | /api/tasks/:taskId |
App | Task detail view |
Exceptions
| Method | Endpoint | Auth | Purpose |
|---|---|---|---|
| POST | /api/exception-stack-traces |
App | Grouped exception list |
| POST | /api/exception-stack-traces/archive |
App+Write | Archive exceptions |
| POST | /api/exception-stack-traces/unarchive |
App+Write | Unarchive exceptions |
| POST | /api/exception-stack-traces/by-id/:exceptionId |
App | Single exception by ID |
| POST | /api/exception-stack-traces/:hash |
App | Exception by hash |
Organization Management
| Method | Endpoint | Auth | Purpose |
|---|---|---|---|
| GET | /api/organizations/:orgId/settings |
Admin | Get org settings |
| PUT | /api/organizations/:orgId/settings |
Admin | Update org settings |
| GET | /api/organizations/:orgId/members |
Admin | List members |
| PUT | /api/organizations/:orgId/members/:userId |
Admin | Update member role |
| DELETE | /api/organizations/:orgId/members/:userId |
Admin | Remove member |
Invitations
| Method | Endpoint | Auth | Purpose |
|---|---|---|---|
| POST | /api/organizations/:orgId/invitations |
Admin | Send invitation |
| GET | /api/organizations/:orgId/invitations |
Admin | List invitations |
| DELETE | /api/organizations/:orgId/invitations/:id |
Admin | Revoke invitation |
| GET | /api/invitations/:token |
None | Get invitation info |
| POST | /api/invitations/:token/accept |
None | Accept (new user) |
| POST | /api/invitations/:token/accept-existing |
App | Accept (existing user) |
Logs
| Method | Endpoint | Auth | Purpose |
|---|---|---|---|
| POST | /api/logs |
App | List/search logs (filters: severity, service, trace, body) |
Source Maps
| Method | Endpoint | Auth | Purpose |
|---|---|---|---|
| POST | /api/sourcemaps/upload |
SourceMap | Upload source map file |
- Gzip Middleware: Decompresses request body (SDK sends gzipped data)
- Auth Middleware: Validates
Authorization: Bearer <project_token> - Parse Frame: JSON decode into
models.Frame(transactions, exceptions, metrics) - Batch Insert: Repository methods insert batches into ClickHouse
// backend/app/controllers/clientcontrollers/report.go
func (c *ReportController) Report(ctx *gin.Context) {
var frame models.Frame
ctx.BindJSON(&frame)
// Insert each data type
transactionRepo.BatchInsert(frame.Transactions)
exceptionRepo.BatchInsert(frame.Exceptions)
metricRepo.BatchInsert(frame.Metrics)
}| Table | Purpose | Partitioning |
|---|---|---|
transactions |
HTTP request metadata | Monthly (toYYYYMM(timestamp)) |
exception_stack_traces |
Exceptions with stack traces | Monthly |
metric_records |
Time-series system metrics | Monthly |
endpoints |
Endpoint aggregates (materialized) | None |
archived_exceptions |
Archived/resolved exceptions | None |
log_records |
OTel logs with 3 attribute maps (resource/scope/log), 30-day TTL | Daily (toDate(timestamp)) |
| Table | Purpose |
|---|---|
users |
User accounts with email/password |
organizations |
Multi-tenant organizations |
organization_users |
Junction table linking users to organizations with roles |
projects |
Project config + tokens, linked to organizations |
invitations |
Team invitations with token, role, expiry |
source_maps |
Uploaded source map files (project, version, storage key) |
metric_registry |
Custom metric definitions (type, unit, description) |
widget_groups |
Dashboard widget groups (name, default flag) |
widget_group_widgets |
Individual widgets within groups (type, config, position) |
- PostgreSQL: Relational/config data needing ACID, frequent updates, JOINs, low volume (users, organizations, projects, invitations, widgets, source maps, metric registry)
- ClickHouse: High-volume append-only telemetry with time-series aggregations, batch inserts only (transactions, exceptions, metric points, spans, tasks, sessions)
- Rule of thumb: "Will this data be updated after creation?" → PostgreSQL. "Is this immutable, time-stamped, high-volume data queried with aggregations?" → ClickHouse.
In SQLite mode (DB_TYPE=sqlite), the backend uses two separate SQLite databases mirroring the PostgreSQL/ClickHouse split:
| Database | Variable | File | Purpose | Transactions |
|---|---|---|---|---|
| Main DB | db.DB |
traceway.db |
PostgreSQL replacement — relational/config data | Yes (middleware.Transactional, db.ExecuteTransaction) |
| Telemetry DB | db.TelemetryDB |
traceway_telemetry.db |
ClickHouse replacement — append-only telemetry | No — direct inserts without transactions |
Main DB tables (db.DB — transactional, uses lit with *sql.Tx):
users,organizations,organization_users,projects,invitationssource_maps,metric_registry,widget_groups,widget_group_widgetsnotification_channels,notification_rules,notification_history
Telemetry DB tables (db.TelemetryDB — non-transactional, uses lit with db.TelemetryDB directly):
endpoints,tasks,exception_stack_traces,spans,metric_pointssession_recordings,archived_exceptions,slow_endpoints,fired_notifications
How to access each database in repository code:
// Main DB (PostgreSQL replacement) — use transactions via middleware or ExecuteTransaction
// Repositories receive *sql.Tx from middleware.GetTx(ctx) or db.ExecuteTransaction
user, err := lit.SelectSingleNamed[models.User](tx, "SELECT ... FROM users WHERE ...", lit.P{...})
// Telemetry DB (ClickHouse replacement) — use db.TelemetryDB directly, no transactions
results, err := lit.SelectNamed[endpointRow](db.TelemetryDB, "SELECT ... FROM endpoints WHERE ...", lit.P{...})
// Telemetry inserts — no transaction wrapping
for _, item := range items {
row := modelToRow(item)
lit.InsertExistingUuid(db.TelemetryDB, &row)
}Migrations are split into two directories:
backend/app/migrations/sqlite/— runs ondb.DB(main)backend/app/migrations/sqlite_telemetry/— runs ondb.TelemetryDB(telemetry)
SQLite-specific type helpers (backend/app/repositories/sqlite_types.go):
SQLiteTime— implementssql.Scanner/driver.Valuerfortime.Time↔ SQLite TEXTSQLiteJSONMap— implementssql.Scanner/driver.Valuerformap[string]string↔ SQLite JSON TEXT- Row types (e.g.,
endpointRow,taskRow) wrap domain models with these types for lit compatibility
| Role | Description |
|---|---|
owner |
Full access, can manage organization |
admin |
Full access to projects |
user |
Standard access to projects |
readonly |
Read-only access, cannot create projects or archive exceptions |
The RequireWriteAccess middleware blocks write operations for users with readonly role.
project_id UUID,
timestamp DateTime64(3),
trace_id String,
endpoint String, -- normalized: "GET /api/users"
duration_ms Float64,
status_code UInt16,
app_version String,
server_name String,
-- Indexes: bloom_filter(trace_id), tokenbf_v1(endpoint)project_id UUID,
timestamp DateTime64(3),
hash String, -- normalized hash for grouping
type String, -- error type (e.g., "RuntimeError")
value String, -- error message
stacktrace String, -- full stack trace
tags Map(String, String), -- contextual tags from scopeCRITICAL RULES:
- Each migration file must contain exactly ONE SQL statement
- Only create
.up.sqlfiles (no down migrations) - Use sequential numbering:
NNNN_description.up.sql
Why one statement per file? ClickHouse migration runner executes each file as a single statement. Multiple statements will fail.
Example - Adding two columns requires TWO files:
backend/app/migrations/ch/
├── 0013_add_app_version_to_transactions.up.sql
│ └── ALTER TABLE transactions ADD COLUMN app_version String DEFAULT ''
├── 0014_add_server_name_to_transactions.up.sql
│ └── ALTER TABLE transactions ADD COLUMN server_name String DEFAULT ''
The backend normalizes stack traces before hashing to group identical errors despite different runtime values. This happens in backend/app/repositories/exceptions.go.
Normalization Steps:
- Extract error type only (remove error message content)
- Remove absolute file paths (keep
filename:lineonly) - Replace hexadecimal addresses with
<hex> - Replace UUIDs with
<uuid> - Replace IP addresses with
<ip> - Replace timestamps with
<timestamp> - Replace numeric IDs in paths with
<id> - Normalize whitespace
- Remove ANSI color codes
- Hash the normalized string with SHA-256, truncate to 16 chars
Result: Same logical error gets same hash, even if:
- Error message contains different user IDs
- Stack trace has different memory addresses
- File paths differ between environments
Repositories are exported as package-level singletons for simple access:
// backend/app/repositories/users.go
var UserRepository = userRepository{}
// backend/app/repositories/projects.go
var ProjectRepository = projectRepository{}
// Usage in controllers
user, err := repositories.UserRepository.FindByEmail(tx, email)
project, err := repositories.ProjectRepository.FindById(tx, id)func (r *TransactionRepository) BatchInsert(txns []models.Transaction) error {
batch, _ := r.db.PrepareBatch(ctx, "INSERT INTO transactions ...")
for _, txn := range txns {
batch.Append(txn.ProjectID, txn.Timestamp, ...)
}
return batch.Send()
}// P50, P95, P99 percentiles
query := `
SELECT
endpoint,
count() as count,
quantile(0.5)(duration_ms) as p50,
quantile(0.95)(duration_ms) as p95,
quantile(0.99)(duration_ms) as p99
FROM transactions
WHERE project_id = ? AND timestamp BETWEEN ? AND ?
GROUP BY endpoint
ORDER BY count DESC
`IMPORTANT: When handling errors in controllers, always use c.AbortWithError with traceway.NewStackTraceErrorf instead of c.JSON with a generic error message. This ensures proper error tracking with stack traces.
// CORRECT - Use AbortWithError with descriptive reason
projectId, err := middleware.GetProjectId(c)
if err != nil {
c.AbortWithError(http.StatusInternalServerError, traceway.NewStackTraceErrorf("RequireProjectAccess middleware must be applied: %w", err))
return
}
// WRONG - Do not use c.JSON for internal server errors
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}Key points:
- The reason should describe the actual cause (e.g., "RequireProjectAccess middleware must be applied")
- Use
%wto wrap the original error for proper error chaining - This pattern applies to all 500 Internal Server Error responses
- For client errors (400, 404),
c.JSONwith an error message is acceptable
Non-stopping errors: For errors that should not abort the request (e.g., optional feature failed to load), use traceway.CaptureException to report them instead of log.Printf:
// CORRECT - Report non-stopping errors via traceway
if err != nil {
traceway.CaptureException(fmt.Errorf("failed to read session recording (key=%s): %w", key, err))
}
// WRONG - Do not use log.Printf for errors
if err != nil {
log.Printf("Failed to read session recording (key=%s): %v", key, err)
}Validation error conventions:
400 Bad Request: Malformed requests, missing required params, type errors422 Unprocessable Entity: Business validation in form dialogs (name too long, duplicate name, required field empty). Returnc.JSON(422, gin.H{"error": "descriptive message"}). The frontendapi.tsextracts 422 error messages — dialogs catch and display them inline.
Summary:
- Stopping errors (abort the request):
c.AbortWithError(status, traceway.NewStackTraceErrorf("reason: %w", err)) - Non-stopping errors (continue serving):
traceway.CaptureException(fmt.Errorf("reason: %w", err)) - Validation errors (user-facing):
c.JSON(422, gin.H{"error": "message"})for form validation - Always wrap errors with
traceway.NewStackTraceErrorforfmt.Errorfusing%w— never discard the original error
Location: External repository at /Users/dusanstanojevic/Documents/workspace/go-client
The SDK is a separate Go module that applications import to send telemetry to Traceway.
import (
"github.com/user/traceway" // Core SDK
"github.com/user/traceway/traceway_gin" // Gin middleware
)<project_token>@<server_url>
Examples:
abc123@http://localhost:8082/api/report
abc123@https://traceway.example.com/api/report
The SDK uses a ring buffer to batch telemetry data:
- Data collected into current "frame" (transactions, exceptions, metrics)
- Frame rotates every 5 seconds (configurable via
WithCollectionInterval) - Completed frames uploaded async via HTTP POST
- Failed uploads retry with exponential backoff
- Transactions: HTTP requests (endpoint, duration, status code)
- Exceptions: Errors/panics with full stack traces
- Metrics: System metrics (CPU, memory, Go runtime)
package main
import (
"github.com/gin-gonic/gin"
"traceway"
"traceway/traceway_gin"
)
func main() {
router := gin.Default()
// Initialize with app name and connection string
traceway_gin.Use(router, "myapp", "token@http://localhost:8082/api/report")
// Or with options
traceway_gin.Use(router, "myapp", "token@http://localhost:8082/api/report",
traceway.WithDebug(true),
traceway.WithCollectionInterval(10 * time.Second),
)
router.GET("/api/users", getUsers)
router.Run(":8080")
}The middleware automatically:
- Tracks request duration and status code
- Captures endpoint as
"METHOD /path"(e.g.,"GET /api/users") - Recovers from panics and reports them as exceptions
- Attaches request scope for contextual tags
// Basic capture
traceway.CaptureException(err)
// With additional scope
traceway.CaptureExceptionWithScope(err, scope)
// Panic recovery (use in defer)
defer traceway.Recover()
// Recover with custom scope
defer traceway.RecoverWithScope(scope)// Capture custom metric
traceway.CaptureMetric("custom.metric.name", 42.0)
// Metrics are batched and sent with the next frame// For non-HTTP transactions or custom tracking
txn := traceway.StartTransaction("operation_name")
defer txn.End()
// Add segments for sub-operations
seg := txn.StartSegment("database_query")
// ... do work ...
seg.End()// Capture a task with duration
traceway.CaptureTask("background_job", startTime, endTime, nil)
// Measure a function
result := traceway.MeasureTask("compute", func() interface{} {
return heavyComputation()
})
// Segments within tasks
traceway.CaptureSegment(taskID, "subtask", startTime, endTime)Scopes attach contextual tags to exceptions and transactions.
// Configure tags that apply to all telemetry
traceway.ConfigureScope(func(s *traceway.Scope) {
s.SetTag("environment", "production")
s.SetTag("version", "1.2.3")
s.SetTag("region", "us-east-1")
})func handler(c *gin.Context) {
// Get request-scoped scope from Gin context
scope := traceway_gin.GetScopeFromGin(c)
// Tags only apply to this request
scope.SetTag("user_id", userID)
scope.SetTag("tenant", tenantID)
// Any exceptions captured in this request will include these tags
}scope.SetTag("key", "value") // Set a single tag
scope.SetTags(map[string]string{ // Set multiple tags
"key1": "value1",
"key2": "value2",
})
scope.SetUser(userID) // Shorthand for user_id tag
scope.SetExtra("key", anyValue) // Set extra data (serialized to JSON)traceway.Init(appName, connectionString,
// Debug mode - logs all telemetry to stdout
traceway.WithDebug(true),
// Max frames to keep in ring buffer (default: 20)
traceway.WithMaxCollectionFrames(20),
// How often to rotate frames (default: 5s)
traceway.WithCollectionInterval(5 * time.Second),
// HTTP upload timeout (default: 3s)
traceway.WithUploadTimeout(3 * time.Second),
// Metric collection interval (default: 30s)
traceway.WithMetricInterval(30 * time.Second),
// Disable automatic metric collection
traceway.WithMetricsDisabled(),
// Custom HTTP transport
traceway.WithTransport(customTransport),
)| Metric | Description | Unit |
|---|---|---|
cpu.used_pcnt |
CPU usage percentage | % |
mem.used |
Memory usage | MB |
mem.used_pcnt |
Memory usage percentage | % |
go.go_routines |
Active goroutine count | count |
go.heap_objects |
Heap object count | count |
go.num_gc |
Total GC cycles | count |
go.gc_pause |
Last GC pause time | nanoseconds |
The SDK sends data as gzipped JSON:
{
"app": "myapp",
"transactions": [
{
"trace_id": "abc123",
"endpoint": "GET /api/users",
"duration_ms": 45.2,
"status_code": 200,
"timestamp": "2024-01-15T10:30:00Z"
}
],
"exceptions": [
{
"type": "RuntimeError",
"value": "connection refused",
"stacktrace": "...",
"tags": {"user_id": "123"},
"timestamp": "2024-01-15T10:30:00Z"
}
],
"metrics": [
{
"name": "cpu.used_pcnt",
"value": 45.2,
"timestamp": "2024-01-15T10:30:00Z"
}
]
}-
Add model in
backend/app/models/type NewEntity struct { ID uuid.UUID `json:"id"` Name string `json:"name"` CreatedAt time.Time `json:"created_at"` }
-
Add repository in
backend/app/repositories/func (r *NewEntityRepository) GetAll(projectID uuid.UUID) ([]models.NewEntity, error) { // ClickHouse query }
-
Add controller in
backend/app/controllers/func (c *NewEntityController) List(ctx *gin.Context) { entities, err := repo.GetAll(projectID) ctx.JSON(200, entities) }
-
Register route in
backend/app/controllers/routes.goapi.GET("/new-entities", newEntityController.List)
-
Add frontend API call in
frontend/src/lib/api.tsor directly in page
POST body convention: List/search endpoints use POST with a JSON body containing filters and pagination:
type ListRequest struct {
ProjectId string `json:"projectId"`
FromDate string `json:"fromDate"`
ToDate string `json:"toDate"`
OrderBy string `json:"orderBy"`
Search string `json:"search"`
Pagination PaginationParams `json:"pagination"`
}Paginated response: Use PaginatedResponse[T] from routes.go for all paginated endpoints:
type PaginatedResponse[T any] struct {
Data []T `json:"data"`
Pagination Pagination `json:"pagination"`
}
type Pagination struct {
Page int `json:"page"`
PageSize int `json:"pageSize"`
Total int64 `json:"total"`
TotalPages int64 `json:"totalPages"`
}
type PaginationParams struct {
Page int `json:"page" binding:"min=1"`
PageSize int `json:"pageSize" binding:"min=1,max=100"`
}-
Create route folder:
frontend/src/routes/new-page/ -
Add page component:
+page.sveltewith the standard loading pattern:<script lang="ts"> import { onMount } from 'svelte' import { api } from '$lib/api' import { projectsState } from '$lib/state/projects.svelte' import { ErrorDisplay } from '$lib/components/ui/error-display' let data = $state<DataType[]>([]) let loading = $state(true) let error = $state('') let notFound = $state(false) async function loadData() { loading = true error = '' try { const response = await api.post<ResponseType>('/endpoint', payload, { projectId: projectsState.currentProjectId ?? undefined }) data = response.data || [] } catch (e: any) { if (e.status === 404) { notFound = true } else { error = e.message || 'Failed to load data' } } finally { loading = false } } onMount(() => { loadData() }) </script> {#if loading} <LoadingCircle size="xlg" /> {:else if notFound} <ErrorDisplay status={404} title="Not Found" description="..." onRetry={() => loadData()} /> {:else if error} <ErrorDisplay status={400} title="Error" description={error} onRetry={() => loadData()} /> {:else} <!-- Content --> {/if}
-
Add data loading (optional):
+page.tsfor URL paramsexport const load = async ({ params }) => { return { param: params.id } }
-
Add navigation in
src/lib/components/app-sidebar.svelte
-
Ensure SDK captures metric (or add to
traceway.gometrics collection) -
Add repository query in
backend/app/repositories/metrics.gofunc (r *MetricRepository) GetNewMetric(projectID uuid.UUID, from, to time.Time) ([]MetricPoint, error) { // Query metric_records table }
-
Add to dashboard controller in
backend/app/controllers/dashboard.go -
Frontend auto-renders from API response (metrics dashboard uses dynamic rendering)
-
Create migration file (remember: ONE statement per file!)
backend/app/migrations/ch/0015_add_new_column.up.sqlALTER TABLE transactions ADD COLUMN new_column String DEFAULT ''
-
Update model in
backend/app/models/ -
Update repository queries to include new column
-
Run migrations: Backend runs migrations automatically on startup
-
Import and add state:
import { getSortState, setSortState, handleSortClick } from '$lib/utils/sort-storage' import type { SortState } from '$lib/utils/sort-storage' let sortState = $state<SortState>(getSortState('page-key', { field: 'default_column', direction: 'desc' }))
-
Add sort handler:
function onSortClick(field: string) { sortState = handleSortClick(field, sortState.field, sortState.direction, 'desc') setSortState('page-key', sortState) }
-
Use TracewayTableHeader:
<TracewayTableHeader label="Column" column="column_name" orderBy={`${sortState.field} ${sortState.direction}`} onclick={() => onSortClick('column_name')} />
-
Pass to API call - convert to backend format:
"column asc"or"column desc":const orderBy = `${sortState.field} ${sortState.direction}`
Every framework addition touches multiple files across backend, frontend, and docs. Use this checklist to avoid missing any:
- Backend —
backend/app/controllers/project.controller.go: Add tovalidFrameworksmap and update the validation error message - Frontend state —
frontend/src/lib/state/projects.svelte.ts: Add toFrameworktype union andFRAMEWORK_LABELSmap - Frontend combobox —
frontend/src/lib/components/framework-combobox.svelte: Add entry with correct group (Go / JavaScript / PHP / etc.) - Frontend icon —
frontend/src/lib/components/framework-icon.svelte: Add icon mapping for the new framework value - Framework code —
frontend/src/lib/utils/framework-code.ts: Add install command, integration code snippet, label, code language, and testing routes - Connection page —
frontend/src/routes/connection/+page.svelte: Add highlight language mapping and install description - Dashboard page —
frontend/src/routes/+page.svelte: Add highlight language mapping - Docs sidebar —
docs/pages/client/_meta.json: Add navigation entry - Docs SDK selector —
docs/components/SdkContext.jsx: Add toSDK_OPTIONSarray andPATH_SDK_MAPobject - Docs framework picker —
docs/components/FrameworkPicker.jsx: Add card toFRAMEWORKSarray - Docs icon —
docs/public/: Add framework icon (PNG, ~45x45) - Docs page —
docs/pages/client/<framework>/: Create_meta.jsonandindex.mdx - Docs OTel language table —
docs/pages/client/otel/index.mdx: Add row if the framework uses OTLP