Self-hostable log and trace storage and search. v1 is feature-complete and shipped: OTLP/HTTP ingest (JSON + protobuf, plus a simple JSON fallback endpoint), OTel-compatible ClickHouse logs table, a log viewer UI (time range, project/service/severity filters, full-text search, live tail), and a built-in overview dashboard. Traces shipped 2026-08-30: POST /api/v1/traces, an otel_traces table with a trace_summary aggregate, a trace list, a span waterfall, per-service latency, and links between a log line and its span in both directions. Still explicitly out of scope: metrics, alerting, user-defined dashboards, saved searches, eBPF, S3 tiering, replication, billing, and OTLP over gRPC. Push back on scope creep.
- Laravel 13 (PHP 8.4) + Inertia v3 + Vue 3 + Tailwind v4 (shadcn-vue), Pest, SQLite app DB.
- ClickHouse stores the logs and traces — accessed over its HTTP interface via
App\Services\ClickHouse\ClickHouseClient(LaravelHttpfacade, no ClickHouse composer package — keep it that way). Config:config/clickhouse.php/CLICKHOUSE_*env vars. - Deploy target: Traefik via Coolify on one OVH dedicated box, Octane/FrankenPHP.
| Area | Where |
|---|---|
| ClickHouse client + exceptions | app/Services/ClickHouse/ |
| ClickHouse DDL (idempotent, filename order) | database/clickhouse/*.sql, applied by php artisan clickhouse:migrate |
| Log ingest (OTLP JSON/protobuf + simple JSON) | routes/api.php, app/Http/Controllers/Api/, mappers in app/Services/Ingest/ |
| Envelope ingest (DSN clients; Sentry-SDK compatible) | app/Services/Ingest/Envelope/, Api\EnvelopeIngestController, App\Http\Middleware\AuthenticatePublicKey (alias project.public-key). Browser access is a per-project origin allow list (projects.allowed_origins, HandleEnvelopeCors, alias envelope.cors); config/cors.php covers api/v1/* only. Vendor names live in the docs only — see .ai/rules/envelope-ingest.md |
| API-key auth (key -> project) | App\Http\Middleware\AuthenticateProjectApiKey, alias project.api-key; keys are bilis_-prefixed, only sha256 hash stored. Each key also carries a public half (bilis_pk_, stored in plaintext) that a DSN is built from (App\Models\ProjectApiKey) |
| Monolog shipper (in-app) | app/Logging/BilisLogger.php (custom-driver factory) + BilisHandler.php; buffered batch POST to the simple ingest endpoint, flushed on terminating()/close()/full buffer. Channel bilis in config/logging.php, off unless LOG_STACK names it; inert without BILIS_ENDPOINT/BILIS_API_KEY |
| Log querying for the UI | app/Services/Logs/ (LogQuery, LogFilters, SeverityLevel) |
| Trace ingest (OTLP JSON + protobuf) | POST /api/v1/traces in routes/api.php, Api\OtlpTraceController, App\Services\Ingest\{OtlpTraceMapper,MappedSpans,SpanWriter,SpanSemantics}. gRPC is not supported and is documented as such |
| Trace querying for the UI | app/Services/Traces/ (TraceQuery, TraceFilters, SpanTree). The list reads trace_summary and re-aggregates; the waterfall reads otel_traces always inside a time window; TraceQuery::tail() is the list's live poll — same query, upper time bound dropped; linkedTraces() answers which traces a span's links actually resolve to |
| Trace viewer pages | TracesController, resources/js/pages/traces/{Index,Latency,Show}.vue. Two tabs — the list (traces.index, polling traces.tail every 5s) and service latency (traces.latency) — joined by TracesTabs.vue and sharing one toolbar and one query string (traceFilterQuery() in resources/js/lib/traces.ts). Waterfall: SpanWaterfall.vue (time axis, gridlines, legend, collapse) + SpanWaterfallRow.vue; detail: SpanDetailPanel.vue (tabbed attributes/events/links); header cells: TraceFact.vue; list: TraceListRow.vue, TracesToolbar.vue, ServiceLatencySection.vue |
| Log → trace preview panel | TracePanel.vue beside the stream in resources/js/pages/logs/Index.vue, fed by traces.panel (TracesController::panel) over XHR. An in-flow column, never an overlay: the stream narrows and stays clickable so the panel swaps from row to row. LogRowActions.vue emits; the page owns the state and drops stale responses. Reuses SpanWaterfall.vue in compact mode |
| One-off ClickHouse maintenance | clickhouse:materialize-index (ClickHouseMaterializeIndexCommand) — deliberately not part of clickhouse:migrate, which runs on every container boot |
| Log viewer page | LogsController, resources/js/pages/logs/, LogsToolbar.vue, LogEntryRow.vue, resources/js/lib/logs.ts |
| Projects (team-scoped, slug route key) | App\Models\Project, belongs to existing Teams system |
| Projects & API keys UI | ProjectController, ProjectApiKeyController, resources/js/pages/projects/, project/API-key modals in resources/js/components/; {project} / {apiKey} route bindings are team-scoped in AppServiceProvider |
| Contextual onboarding (no projects -> no logs -> ready) | App\Services\Logs\LogOnboarding + LogQuery::hasAnyLogs(); onboarding prop from LogsController and DashboardController; GetStartedPanel.vue renders both steps on the logs page and the dashboard (until ready) . Traces do not join this ladder: TraceQuery::hasAnyTraces() drives a local empty state on the traces page, so "never sent a span" reads differently from "nothing in this window" |
| Left navigation | AppSidebar.vue (Platform: Dashboard, Logs, Traces, Autofix; Resources: Projects), groups rendered by NavMain.vue (label prop); active state matches nested URLs via isCurrentOrParentUrl |
| Public chrome, shared by every logged-out surface | resources/views/components/public/{head,header,footer}.blade.php, social card resources/views/components/social-meta.blade.php, inline animated mark resources/views/components/marketing/logo-{mark,icon}.blade.php; pinned by tests/Feature/PublicChromeTest.php |
| Styleguide / component showcase (public) | /styleguide route, resources/js/pages/styleguide/, Inertia root view resources/views/styleguide.blade.php |
| Marketing pages (public, Blade only) | resources/views/marketing/ (home, features, legal), partials in resources/views/marketing/partials/, layout resources/views/components/layouts/marketing.blade.php; routes home / features |
| Landing-page JavaScript (one Blade-only bundle) | resources/js/marketing/marketing.ts → hero-shader (Paper ShaderMount, resources/js/marketing/fold-gradient-shader.ts), live-tail, copy. See .ai/rules/marketing.md |
| Blog (Blade + CommonMark) | resources/blog/{yyyy-mm-dd}-{slug}.md (front matter: title/description/date/author, draft: true to withhold), App\Services\Blog\, BlogController, resources/views/blog/, layout resources/views/components/layouts/blog.blade.php (carries the Atom autodiscovery link); routes blog.index / blog.show / blog.feed |
| Markdown shared by docs and blog | App\Services\Markdown\ (FrontMatter / MarkdownRenderer / RenderedMarkdown). Docs-only behaviour — the section tree, nav order, /docs/*.md raw output — stays in App\Services\Docs\ |
| Public docs (Blade + CommonMark) | resources/docs/{section}/{page}.md (front matter: title/description/order, _section.md per group), App\Services\Docs\ (DocsRepository/DocsPage/DocsSection), DocsController, resources/views/docs/, layout resources/views/components/layouts/docs.blade.php, nav resources/views/components/docs/nav.blade.php, prose styles .docs-prose in app.css; routes docs.index / docs.show |
| Charts (Apache ECharts) | ChartCanvas.vue wrapper; register chart types in resources/js/lib/echarts.ts; theme comes from CSS tokens via useChartTokens — never hardcode chart colours |
- Ingest never returns 400. Malformed records are skipped best-effort with counts (
partialSuccessfor OTLP). ClickHouse failure -> 503 +Retry-After. The client is never blamed. - ProjectId comes only from the authenticated API key (ingest) or the current team's projects (UI). Never from the payload or from a slug reaching SQL. It is a
Stringcolumn, so ids are cast at the controller boundary. The sort key leading with it is clustering, not isolation — never call it a tenancy boundary. database/clickhouse/SCHEMA.mdis the source of truth for theotel_logs,otel_tracesandtrace_summarytables: pinned collector tag, exact DDL, rules R1–R12. Column names and types belong to the OTel exporter (R1);ORDER BY,PARTITION BY,TTL, indexes andProjectIdare ours. Read it before touching the DDL or any query against the table.- Every log query follows R4. Sort key
(ProjectId, Timestamp, ServiceName): a plainProjectId IN … AND Timestamp >= {from} AND Timestamp <= {to},ORDER BY Timestamp DESC, no bucket expression. The base predicate is built in one method (LogQuery::conditions()); user filters append to it and never replace the ProjectId predicate. - Body search must match the index expression exactly (R5, ClickHouse >= 26.2):
hasAnyTokens(lower(Body), [lower({q:String})])againstINDEX idx_lower_body lower(Body) TYPE text(tokenizer = 'splitByNonAlpha'). Thelower()wrapper stays on both sides — the tokenizer splits but does not fold case, and dropping it loses case-insensitivity and the index at once, silently. The non-token fallback islower(Body) LIKE lower(...), notBody ILIKE, for the same reason. Prove index use withSETTINGS force_data_skipping_indices = 'idx_lower_body'. - Traces:
StatusCode/SpanKindstore the exporter'sString()literals (Error,Server), never the proto enum names —trace_summary_mvcounts errors withcountIf(StatusCode = 'Error')(R10).trace_summaryis anAggregatingMergeTree, so every read re-aggregates withGROUP BY ProjectId, TraceId; without it a trace whose spans arrived in several insert blocks is returned several times with partial counts (R11).Events.*/Links.*are position-aligned parallel arrays (R12). - All ClickHouse SQL is parameterized with
{name:Type}server-side placeholders — never string-interpolate values. - Inserts use
async_insert=1/wait_for_async_insert=0— success means queued, not durable. - OTLP protobuf is decoded in-process, in pure PHP (
app/Services/Ingest/Protobuf/) — no composer package, noext-protobuf, and it stays that way. It emits the same array shape the JSON path produces, soOtlpLogMappernever learns which encoding arrived; the equivalence is asserted against fixtures captured from a real Go exporter (tests/Fixtures/otlp/).BILIS_OTLP_PROTOBUF=falserestores the old 415. OTLP over gRPC is still out of scope. - Bilis is deployed and the tables hold real data. A schema change needs the
CREATEupdated and a numbered, doubly-guardedALTERfile, becauseCREATE TABLE IF NOT EXISTScannot alter an existing table anddocker-entrypoint.shrunsclickhouse:migrateonce per container role.0005_alter_otel_logs_body_index.sqlis the worked example. There is still no backup. - Ingest bodies sent with
Content-Encoding: gzip/deflateare inflated (App\Services\Ingest\RequestBody, capped); anything else -> 415 naming what is supported.
-
Public marketing pages are Blade, never Inertia. Anything a logged-out visitor is meant to read (the
/landing page and whatever follows it) lives inresources/views/marketing/under<x-layouts.marketing>and loads@vite('resources/css/app.css')only — no Inertia bundle. Inertia is for in-app, authenticated surfaces. Inertia SSR is off (config/inertia.php,inertia({ ssr: false })) and stays off: the pages that needed pre-rendering are Blade now./styleguideis the one sanctioned exception, and it is not a licence for a second one. It is a live gallery of the app's own Vue components, so rendering it as Blade would defeat its purpose; it is public, boots the app bundle, and paints after JavaScript. It still wears the shared Blade chrome — its Inertia root view (resources/views/styleguide.blade.php) wraps@inertiain<x-public.header>/<x-public.footer>— so the header and footer are server-rendered there like everywhere else. Any other public page goes in Blade. -
One header, one footer, one
<head>, for every public surface. The landing page, features, blog, docs and styleguide are built three different ways, and a visitor must not be able to tell: they all render<x-public.header>/<x-public.footer>/<x-public.head>. Add a nav item inheader.blade.phpand every surface gets it. The pre-paintbackground-colorand thetheme-colormetas inhead.blade.phpmust stay equal to--backgroundin both modes. -
Every new reusable Vue component must be added to the
/styleguideshowcase (resources/js/pages/styleguide/) in the same change — add it to the matching section (or a new one) with realistic Bilis-flavored demo content. A component that isn't in the styleguide isn't done. -
Charting: use Apache ECharts (
echarts) for all charts — not chart.js, not hand-rolled SVG. It is installed and wired: import tree-shakeably fromecharts/core, register new chart types inresources/js/lib/echarts.ts, always render throughChartCanvas.vue, and take colours fromuseChartTokens— never a literal. Showcase every new chart in the styleguide's Charts section.
- Do not commit. The user commits when they're ready — leave the working tree for them to review and stage. Only commit if they explicitly ask for one in the moment.
Colour belongs to data; the chrome is achromatic. The whole interface — surfaces, borders, buttons, focus rings, nav, icons, type — is built from one neutral ladder (hue 225 at 8–20% saturation, per-mode) defined in resources/css/app.css. There is no accent colour, no brand hue in the UI, and no coloured primary button. Only two families carry hue, and both are data:
- Severity —
--severity-{trace,debug,info,warn,error,fatal}and thetext-severity-*/bg-severity-*utilities. - Chart series —
--chart-1..5. This is also what colours a span waterfall bar, keyed by service: a service is a data series, so the waterfall spends this palette rather than inventing a family of its own (serviceColours()inresources/js/lib/traces.ts, five slots cycled, legend above the chart). A failed span overrides to--severity-error, because "this broke" outranks "this belongs to payments".
Both are drawn from the Bilis mark's tail (--color-mark-{gold,teal,crimson,navy}), which is the palette's origin and is never used for chrome. destructive is the single stated exception, because it warns about an action rather than describing data.
Dark is the designed-for mode; light is authored separately, never derived. Font: Geist for the interface, Geist Mono for log data — self-hosted via the Vite font plugin. IBM Plex Mono is available as a per-account alternate (Settings -> Appearance). Wordmark: "Bilis" with the mark (AppLogo.vue / AppLogoIcon.vue), which keeps its tail colours. Living reference: the /styleguide page. Full system: DESIGN.md.
composer run dev # app + vite + queue + pail
php artisan test --compact # Pest (use --filter/paths for speed)
vendor/bin/pint --dirty --format agent
vendor/bin/phpstan analyse # larastan level per phpstan.neon — keep it clean
php artisan clickhouse:migrate
php artisan clickhouse:materialize-index # one-off, operator-run; never on boot
npm run build # must pass (vue-tsc + vite)
composer hooks:install # enable .githooks/pre-commit (lint/format/types before every commit, no tests; `composer setup` does this too)
php artisan wayfinder:generate --with-form # ALWAYS --with-form; without it .form() is stripped from every generated route and ~19 files breakThe Laravel Boost guidelines are specifically curated by Laravel maintainers for this application. These guidelines should be followed closely to ensure the best experience when building Laravel applications.
This application is a Laravel application running on PHP 8.4. You are an expert with the Laravel ecosystem. Always use the APIs that match the installed major version of each package — do not assume a version.
Before relying on a package's API, confirm its installed version:
- PHP packages: run
composer show --directto list direct dependencies with versions, orcomposer show <vendor/package>for a single package. - JS packages: check
package.jsonfor the installed versions.
This project has domain-specific skills available in **/skills/**. You MUST activate the relevant skill whenever you work in that domain—don't wait until you're stuck.
- You must follow all existing code conventions used in this application. When creating or editing a file, check sibling files for the correct structure, approach, and naming.
- Use descriptive names for variables and methods. For example,
isRegisteredForDiscounts, notdiscount(). - Check for existing components to reuse before writing a new one.
- Do not create verification scripts or tinker when tests cover that functionality and prove they work. Unit and feature tests are more important.
- Stick to existing directory structure; don't create new base folders without approval.
- Do not change the application's dependencies without approval.
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run
npm run build,npm run dev, orcomposer run dev. Ask them.
- You must only create documentation files if explicitly requested by the user.
- Be concise in your explanations - focus on what's important rather than explaining obvious details.
=== boost rules ===
- Laravel Boost is an MCP server with tools designed specifically for this application. Prefer Boost tools over manual alternatives like shell commands or file reads.
- Use
database-queryto run read-only queries against the database instead of writing raw SQL in tinker. - Use
database-schemato inspect table structure before writing migrations or models. - Use
get-absolute-urlto resolve the correct scheme, domain, and port for project URLs. Always use this before sharing a URL with the user. - Use
browser-logsto read browser logs, errors, and exceptions. Only recent logs are useful, ignore old entries.
- Use
search-docsbefore changes that depend on Laravel ecosystem APIs, behavior, configuration, or version-specific syntax. Skip it for copy-only edits and other changes where package documentation is irrelevant. Reuse sufficient results already in context instead of searching again. - Pass a
packagesarray to scope results when you know which packages are relevant. - Use multiple broad, topic-based queries:
['rate limiting', 'routing rate limiting', 'routing']. Expect the most relevant results first. - Do not add package names to queries because package info is already shared. Use
test resource table, notfilament 4 test resource table.
- Use words for auto-stemmed AND logic:
rate limitmatches both "rate" AND "limit". - Use
"quoted phrases"for exact position matching:"infinite scroll"requires adjacent words in order. - Combine words and phrases for mixed queries:
middleware "rate limit". - Use multiple queries for OR logic:
queries=["authentication", "middleware"].
- This project contains committed, area-grouped rules in
.ai/ruleswhen that directory exists (settled decisions, non-obvious traps, standing constraints). Framework and package guidelines that only apply to specific paths (testing, frontend, components) also live there, under.ai/rules/boost— this is not just recorded decisions, it is load-bearing guidance you have not seen inline. Before you enter plan mode or create/edit any file, you MUST first: open @.ai/rules/index.md (it maps file globs to rule files), read every rule file whose globs cover the path(s) in scope, and rungrep -rin 'keyword' .ai/rulesto catch what a path match alone misses. Do not write code until you have read and are following every matching rule. If.ai/rulesdoes not exist, continue without it. - Record durable rules with
record-ruleso the next agent or teammate inherits them instead of working them out again. Pass aglob(e.g.app/Http/Controllers/**), a shorttitle, and a few-linenote. Always userecord-rule, never your native memory or notes tool — native memory is personal and session-scoped; only.ai/rulesis shared with the team and persists in the repo.
- Run Artisan commands directly via the command line (e.g.,
php artisan route:list). Usephp artisan listto discover available commands andphp artisan [command] --helpto check parameters. - Inspect routes with
php artisan route:list. Filter with:--method=GET,--name=users,--path=api,--except-vendor,--only-vendor. - Read configuration values using dot notation:
php artisan config:show app.name,php artisan config:show database.default. Or read config files directly from theconfig/directory.
- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code.
- Always use single quotes to prevent shell expansion:
php artisan tinker --execute 'Your::code();'- Double quotes for PHP strings inside:
php artisan tinker --execute 'User::where("active", true)->count();'
- Double quotes for PHP strings inside:
=== php rules ===
- Always use curly braces for control structures, even for single-line bodies.
- Use PHP 8 constructor property promotion:
public function __construct(public GitHub $github) { }. Do not leave empty zero-parameter__construct()methods unless the constructor is private. - Use explicit return type declarations and type hints for all method parameters:
function isAccessible(User $user, ?string $path = null): bool - Use TitleCase for Enum keys:
FavoritePerson,BestLake,Monthly. - Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic.
- Use array shape type definitions in PHPDoc blocks.
=== deployments rules ===
- Laravel can be deployed using Laravel Cloud, which is the fastest way to deploy and scale production Laravel applications.
=== herd rules ===
- The application is served by Laravel Herd at
https?://[kebab-case-project-dir].test. Use theget-absolute-urltool to generate valid URLs. Never run commands to serve the site. It is always available. - Use the
herdCLI to manage services, PHP versions, and sites (e.g.herd sites,herd services:start <service>,herd php:list). Runherd listto discover all available commands.
=== tests rules ===
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use
php artisan test --compactwith a specific filename or filter.
=== inertia-laravel/core rules ===
- Inertia creates fully client-side rendered SPAs without modern SPA complexity, leveraging existing server-side patterns.
- Components live in
resources/js/pages(unless specified invite.config.js). UseInertia::render()for server-side routing instead of Blade views. - ALWAYS use
search-docstool for version-specific Inertia documentation and updated code examples. - IMPORTANT: Activate
inertia-vue-developmentwhen working with Inertia Vue client-side patterns.
- Use all Inertia features from v1, v2, and v3. Check the documentation before making changes to ensure the correct approach.
- New v3 features: standalone HTTP requests (
useHttphook), optimistic updates with automatic rollback, layout props (useLayoutPropshook), instant visits, simplified SSR via@inertiajs/viteplugin, custom exception handling for error pages. - Carried over from v2: deferred props, infinite scroll, merging props, polling, prefetching, once props, flash data.
- When using deferred props, add an empty state with a pulsing or animated skeleton.
- Axios has been removed. Use the built-in XHR client with interceptors, or install Axios separately if needed.
Inertia::lazy()/LazyProphas been removed. UseInertia::optional()instead.- Prop types (
Inertia::optional(),Inertia::defer(),Inertia::merge()) work inside nested arrays with dot-notation paths. - SSR works automatically in Vite dev mode with
@inertiajs/vite- no separate Node.js server needed during development. - Event renames:
invalidis nowhttpException,exceptionis nownetworkError. router.cancel()replaced byrouter.cancelAll().- The
futureconfiguration namespace has been removed - all v2 future options are now always enabled.
=== laravel/core rules ===
- Use
php artisan make:commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands usingphp artisan listand check their parameters withphp artisan [command] --help. - If you're creating a generic PHP class, use
php artisan make:class. - Pass
--no-interactionto all Artisan commands to ensure they work without user input. You should also pass the correct--optionsto ensure correct behavior.
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using
php artisan make:model --helpto check the available options.
- For APIs, default to using Eloquent API Resources and API versioning unless existing API routes do not, then you should follow existing application convention.
- When generating links to other pages, prefer named routes and the
route()function.
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as
$this->faker->word()orfake()->randomDigit(). Follow existing conventions whether to use$this->fakerorfake(). - When creating tests, make use of
php artisan make:test [options] {name}to create a feature test, and pass--unitto create a unit test. Most tests should be feature tests.
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run
npm run buildor ask the user to runnpm run devorcomposer run dev.
=== wayfinder/core rules ===
Use Wayfinder to generate TypeScript functions for Laravel routes. Import from @/actions/ (controllers) or @/routes/ (named routes).
=== pint/core rules ===
- If you have modified any PHP files, you must run
vendor/bin/pint --dirty --format agentbefore finalizing changes to ensure your code matches the project's expected style. - Do not run
vendor/bin/pint --test --format agent, simply runvendor/bin/pint --format agentto fix any formatting issues.
=== pest/core rules ===
- This project uses Pest for testing. Create tests:
php artisan make:test --pest {name}. - The
{name}argument should not include the test suite directory. Usephp artisan make:test --pest SomeFeatureTestinstead ofphp artisan make:test --pest Feature/SomeFeatureTest. - Run tests:
php artisan test --compactor filter:php artisan test --compact --filter=testName. - Do NOT delete tests without approval.
=== inertia-vue/core rules ===
Vue components must have a single root element.
- IMPORTANT: Activate
inertia-vue-developmentwhen working with Inertia Vue client-side patterns.