From ab706ba3e9645436fbe429f8baa0c7059a179a96 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 06:04:28 +0000 Subject: [PATCH 1/4] feat(run): --trace-otlp to export traces to a collector (flame charts) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `--trace` console exporter omits start/end timestamps and parent span IDs, so call trees and durations can't be reconstructed from it — for flame charts you had to hand-set three OTEL_* env vars (OTEL_TRACES_EXPORTER=otlp, OTEL_EXPORTER_OTLP_PROTOCOL, OTEL_EXPORTER_OTLP_ENDPOINT). Add `mxcli run --local --trace-otlp ` (implies --trace): it switches the traces exporter to OTLP and points it at the collector (protocol http/protobuf). User-set OTEL_* still take precedence, so a fully hand-rolled env keeps working; without the flag the console default is unchanged. Reported from the sudoku project (OpenTelemetry tracing follow-up on the console-exporter limitation). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4 --- .claude/skills/mendix/run-local.md | 16 +++++++++++++-- CLAUDE.md | 2 +- cmd/mxcli/cmd_run.go | 12 +++++++++-- cmd/mxcli/docker/localboot.go | 32 +++++++++++++++++++++++------- cmd/mxcli/docker/runlocal.go | 15 +++++++++++--- cmd/mxcli/docker/runlocal_test.go | 26 ++++++++++++++++++++++-- docs-site/src/tools/run-local.md | 15 ++++++++++++-- 7 files changed, 99 insertions(+), 19 deletions(-) diff --git a/.claude/skills/mendix/run-local.md b/.claude/skills/mendix/run-local.md index 474f119fc..8e894da88 100644 --- a/.claude/skills/mendix/run-local.md +++ b/.claude/skills/mendix/run-local.md @@ -122,6 +122,7 @@ Launch `run --local` as the **sole** command in its invocation (don't chain a tr | `--metrics` | off | Register a Prometheus meter registry at boot; the runtime serves metrics at `http://127.0.0.1:/prometheus` | | `--trace` | off | Enable OpenTelemetry tracing (bundled agent, console exporter → the runtime log) with default span filters | | `--trace-service` | `.mpr` name | `OTEL_SERVICE_NAME` under `--trace` | +| `--trace-otlp` | off (console) | Export traces to this OTLP collector endpoint (e.g. `http://127.0.0.1:4318`) instead of the console. Implies `--trace`. Needed for flame charts | | `--runtime-setting Key=Value` | — | Merge an extra runtime setting into the boot config (Value parsed as JSON when possible). Repeatable. | ## Metrics and OpenTelemetry @@ -159,8 +160,19 @@ with metrics/logs exporters off. the microflow-level spans). Override with `--runtime-setting 'OpenTelemetry._RuntimeSpanFilters=[…]'`. -**Export to a collector (OTLP) instead of the console:** set the OTEL env yourself -before running — `--trace` won't override an exporter you've already set: +**Export to a collector (OTLP) instead of the console:** the console exporter +omits start/end timestamps and parent span IDs, so call trees and durations can't +be reconstructed from it — for flame charts, export to an OTLP collector. Pass +`--trace-otlp ` (implies `--trace`); mxcli sets +`OTEL_TRACES_EXPORTER=otlp`, `OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf`, and +`OTEL_EXPORTER_OTLP_ENDPOINT` for you: + +```bash +mxcli run --local -p app.mpr --trace-otlp http://127.0.0.1:4318 +``` + +You can still set the `OTEL_*` env yourself for full control — `--trace` / +`--trace-otlp` never override an exporter you've already set: ```bash export OTEL_TRACES_EXPORTER=otlp OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 diff --git a/CLAUDE.md b/CLAUDE.md index 631135165..e470abcf6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -525,7 +525,7 @@ Full syntax tables for all MDL statements (microflows, pages, security, navigati - Docker build integration (`mxcli docker build`) with PAD patching (Phase 1) - Warm local dev loop (`mxcli run --local [--watch] [--screenshot]`): Docker-free `mxbuild --serve` + standalone runtime, hot `reload_model` for behavioural changes and restart+DDL for structural ones (chosen from the serve build's `restartRequired`). Bundles the browser client (`web/dist/` via mxbuild's rollup runner, which the serve Deploy target skips) so Mendix 11.x apps render in a browser. `--watch` keeps an incremental rollup bundler hot (CHOKIDAR_USEPOLLING for container fs; ~3-4s page re-bundle, skipped for model-only edits) and watches only model source (`.mpr`+`mprcontents/`). `--ensure-db` provisions the local Postgres + app database if missing; `--setup` does the non-blocking prerequisites (cache mxbuild+runtime, ensure DB) and exits — `mxcli init` wires it into a Claude Code SessionStart hook so a fresh/reaped web session self-bootstraps, and `docs-site/src/tools/bootstrap-prompt.md` is the empty-repo seed prompt. `--screenshot` captures a Playwright PNG each change (pixel-perfect page loop), with `--screenshot-url` deep links (repeatable for multi-page sets, one PNG per page) and `--screenshot-user`/`--screenshot-password` form login (session saved as Playwright storage state, reused via `screenshot --load-storage`). See `docs/11-proposals/PROPOSAL_mxcli_dev_warm_loop.md` - External browser preview (`mxcli run --hub ` + `mxcli tunnel-hub`): the app stays local and reverse-tunnels out over a single 443 connection (embedded chisel) to a static relay, so it is reachable in a browser at a public URL — works from egress-only environments (Claude Code web), verified live through the session's MITM egress proxy. `run --hub` implies `--local`, boots the runtime with `ApplicationRootUrl` set to the assigned URL (so the SPA/`originURI` work under the public origin), resolves the control proxy honouring `NO_PROXY`, and retries forever. `mxcli tunnel-hub --domain ` is the **multi-tenant** relay: a registry keyed by prefix/project/solution/branch/worktree (stable URLs on reconnect) fronts many previews at per-subdomain hosts (`[prefix-]project[-branch].`; main collapses to the project) over one 443 with per-subdomain autocert, a registration API (`/api/register|status|deregister|backends`), and a sortable availability overview at `hub./`. Client identity flags: `--hub-prefix`/`--hub-project`/`--hub-solution`/`--hub-branch`/`--hub-worktree` (project + branch auto-detected). Package: `cmd/mxcli/tunnelhub/`. See `docs/11-proposals/PROPOSAL_mxcli_dev_warm_loop.md` (slices 3–4) -- Runtime metrics + settings passthrough (`mxcli run --local --metrics` / `--runtime-setting Key=Value`): `--metrics` registers a Prometheus Micrometer registry at boot (served at `http://127.0.0.1:/prometheus`); `--runtime-setting` merges arbitrary runtime config (e.g. `Metrics.Registries` for otlp/influx/statsd, or `OpenTelemetry._RuntimeSpanFilters`) into mxcli's **single** boot `update_configuration` call — the admin action replaces rather than merges, so folding settings into the one boot call is the only safe way. OTel traces via `--trace` attach the bundled `opentelemetry-javaagent` to the runtime JVM (console exporter → the tee'd runtime log) and ship default `OpenTelemetry._RuntimeSpanFilters` (unfiltered per-activity tracing is ~10× slower); `--trace-service` sets `OTEL_SERVICE_NAME`, and user-set `OTEL_*` env (e.g. an OTLP collector) is respected. See `.claude/skills/mendix/run-local.md` +- Runtime metrics + settings passthrough (`mxcli run --local --metrics` / `--runtime-setting Key=Value`): `--metrics` registers a Prometheus Micrometer registry at boot (served at `http://127.0.0.1:/prometheus`); `--runtime-setting` merges arbitrary runtime config (e.g. `Metrics.Registries` for otlp/influx/statsd, or `OpenTelemetry._RuntimeSpanFilters`) into mxcli's **single** boot `update_configuration` call — the admin action replaces rather than merges, so folding settings into the one boot call is the only safe way. OTel traces via `--trace` attach the bundled `opentelemetry-javaagent` to the runtime JVM (console exporter → the tee'd runtime log) and ship default `OpenTelemetry._RuntimeSpanFilters` (unfiltered per-activity tracing is ~10× slower); `--trace-service` sets `OTEL_SERVICE_NAME`. The console exporter omits timestamps/parent span IDs (no flame charts), so `--trace-otlp ` (implies `--trace`) switches to the OTLP exporter (protocol `http/protobuf`) pointed at a collector; user-set `OTEL_*` env still takes precedence. See `.claude/skills/mendix/run-local.md` - OQL query execution against running runtime (`mxcli oql`) - Microflow/nanoflow debugger (`mxcli debug`): set breakpoints **by name** (activity resolved from the model), inspect paused flows + variables, step over/into/out, continue — against a `run --local` runtime. Two M2EE planes wired behind one command (admin `enable/disable/status`, app `/debugger/` session); `run --local --debug` enables it at boot. **Nanoflows** are auto-detected (uses the `nanoflow_name` breakpoint param; paused nanoflows are merged from `poll_events`, which `get_paused_microflows` omits). Nanoflow `LOG` output is rewritten to the `Client_Nanoflow` node in the runtime log. See `.claude/skills/mendix/debug-microflows.md` and `docs/11-proposals/PROPOSAL_microflow_debugger.md` - Business event services (SHOW/DESCRIBE/CREATE/DROP) diff --git a/cmd/mxcli/cmd_run.go b/cmd/mxcli/cmd_run.go index 2dc995351..a7c9823da 100644 --- a/cmd/mxcli/cmd_run.go +++ b/cmd/mxcli/cmd_run.go @@ -54,8 +54,10 @@ runtime behaviour; it is turned back off on shutdown. With --metrics, a Prometheus meter registry is registered at boot and the runtime serves metrics at http://127.0.0.1:/prometheus. With --trace, the bundled OpenTelemetry agent is attached (spans -> the runtime log via the console -exporter) with default span filters (unfiltered tracing is ~10x slower). Use ---runtime-setting Key=Value (repeatable) to merge any other runtime setting into +exporter) with default span filters (unfiltered tracing is ~10x slower). The +console exporter omits timestamps and parent span IDs, so for flame charts pass +--trace-otlp to export to an OTLP collector (e.g. http://127.0.0.1:4318) +instead. Use --runtime-setting Key=Value (repeatable) to merge any other runtime setting into the boot config (the admin config action replaces rather than merges, so mxcli folds these into its single boot call), e.g. a different Metrics.Registries type or custom OpenTelemetry span filters. @@ -120,6 +122,10 @@ Examples: runtimeSettings, _ := cmd.Flags().GetStringArray("runtime-setting") trace, _ := cmd.Flags().GetBool("trace") traceService, _ := cmd.Flags().GetString("trace-service") + traceOTLP, _ := cmd.Flags().GetString("trace-otlp") + if traceOTLP != "" { + trace = true // --trace-otlp implies --trace + } opts := docker.LocalRunOptions{ ProjectPath: projectPath, @@ -148,6 +154,7 @@ Examples: RuntimeSettings: runtimeSettings, Trace: trace, TraceService: traceService, + TraceOTLP: traceOTLP, DB: docker.DBConfig{ Host: dbHost, Name: dbName, @@ -196,5 +203,6 @@ func init() { runCmd.Flags().StringArray("runtime-setting", nil, "Extra runtime setting Key=Value merged into the boot configuration (Value parsed as JSON when possible), e.g. --runtime-setting 'OpenTelemetry._RuntimeSpanFilters=[\"Loop\",\"Gateway\"]'. Repeatable.") runCmd.Flags().Bool("trace", false, "Enable OpenTelemetry tracing: attach the bundled agent (console exporter → the runtime log) and apply default span filters (unfiltered tracing is ~10x slower)") runCmd.Flags().String("trace-service", "", "OTEL_SERVICE_NAME under --trace (default: the .mpr name)") + runCmd.Flags().String("trace-otlp", "", "Export traces to this OTLP collector endpoint (e.g. http://127.0.0.1:4318) instead of the console — needed for flame charts (the console exporter omits timestamps/parent IDs). Implies --trace") rootCmd.AddCommand(runCmd) } diff --git a/cmd/mxcli/docker/localboot.go b/cmd/mxcli/docker/localboot.go index 573a91d07..a11c9d2b1 100644 --- a/cmd/mxcli/docker/localboot.go +++ b/cmd/mxcli/docker/localboot.go @@ -78,6 +78,11 @@ type LocalRuntimeOptions struct { Trace bool // TraceServiceName is OTEL_SERVICE_NAME when Trace is set (default: the app). TraceServiceName string + // TraceOTLPEndpoint, when set, switches the traces exporter from console to + // OTLP and points it at this collector (e.g. http://127.0.0.1:4318) — needed + // for flame charts, since the console exporter omits timestamps/parent IDs. + // Implies Trace. User-set OTEL_* vars still take precedence. + TraceOTLPEndpoint string // DB is the database the runtime connects to. DB DBConfig // ReadyTimeout bounds how long StartLocalRuntime waits for the admin API @@ -162,11 +167,13 @@ func (o *LocalRuntimeOptions) otelAgentJar() (string, error) { // withTraceEnv layers the OpenTelemetry Java-agent + OTEL_* env onto base. The // agent is always attached (via JAVA_TOOL_OPTIONS, appended to any existing -// value); the OTEL_* exporters default to console traces / no metrics+logs but -// are NOT overridden if the caller already set them (so OTLP-to-a-collector via -// the user's own env still works). Traces on the console exporter land in the -// tee'd runtime log. -func withTraceEnv(base []string, agentJar, serviceName string) []string { +// value). The traces exporter defaults to the console (lands in the tee'd +// runtime log) — but when otlpEndpoint is set, it is switched to OTLP and pointed +// at that collector (protocol http/protobuf), which is what flame-chart tools +// need since the console exporter omits timestamps and parent span IDs. Metrics +// and logs exporters default to none. None of these are overridden if the caller +// already set the corresponding OTEL_* var, so a fully hand-rolled env still works. +func withTraceEnv(base []string, agentJar, serviceName, otlpEndpoint string) []string { has := func(key string) bool { for _, e := range base { if strings.HasPrefix(e, key+"=") { @@ -197,7 +204,18 @@ func withTraceEnv(base []string, agentJar, serviceName string) []string { out = append(out, "OTEL_SERVICE_NAME="+serviceName) } if !has("OTEL_TRACES_EXPORTER") { - out = append(out, "OTEL_TRACES_EXPORTER=console") + if otlpEndpoint != "" { + // Export to an OTLP collector so call trees + durations are usable. + out = append(out, "OTEL_TRACES_EXPORTER=otlp") + if !has("OTEL_EXPORTER_OTLP_PROTOCOL") { + out = append(out, "OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf") + } + if !has("OTEL_EXPORTER_OTLP_ENDPOINT") { + out = append(out, "OTEL_EXPORTER_OTLP_ENDPOINT="+otlpEndpoint) + } + } else { + out = append(out, "OTEL_TRACES_EXPORTER=console") + } } if !has("OTEL_METRICS_EXPORTER") { out = append(out, "OTEL_METRICS_EXPORTER=none") @@ -367,7 +385,7 @@ func (rt *LocalRuntime) spawnAndConfigure() error { if err != nil { return fmt.Errorf("--trace: %w", err) } - cmd.Env = withTraceEnv(cmd.Env, jar, rt.opts.TraceServiceName) + cmd.Env = withTraceEnv(cmd.Env, jar, rt.opts.TraceServiceName, rt.opts.TraceOTLPEndpoint) } PrepareMxCommand(cmd) // FreeType LD_PRELOAD workaround, layered on cmd.Env setProcessGroup(cmd) // reap any JVM child on Stop so the port is freed diff --git a/cmd/mxcli/docker/runlocal.go b/cmd/mxcli/docker/runlocal.go index 0ee417fc6..c1d622b9c 100644 --- a/cmd/mxcli/docker/runlocal.go +++ b/cmd/mxcli/docker/runlocal.go @@ -108,6 +108,11 @@ type LocalRunOptions struct { Trace bool // TraceService is OTEL_SERVICE_NAME under --trace (default: the .mpr name). TraceService string + // TraceOTLP, when set, exports traces to this OTLP collector endpoint + // (e.g. http://127.0.0.1:4318) instead of the console — needed for flame + // charts, since the console exporter omits timestamps/parent span IDs. + // Implies Trace. + TraceOTLP string // RuntimeSettings are raw "Key=Value" runtime settings merged into the boot // update_configuration payload (Value is parsed as JSON, else a string), e.g. // 'Metrics.Registries=[{"type":"otlp"}]' or @@ -568,6 +573,7 @@ func RunLocal(opts LocalRunOptions) error { RuntimeSettings: runtimeSettings, Trace: opts.Trace, TraceServiceName: traceService, + TraceOTLPEndpoint: opts.TraceOTLP, Stdout: w, Stderr: stderr, }) @@ -585,10 +591,13 @@ func RunLocal(opts LocalRunOptions) error { fmt.Fprintf(w, "Metrics (Prometheus): http://127.0.0.1:%d/prometheus\n", opts.AdminPort) } if opts.Trace { - if runtimeLog != "" { + switch { + case opts.TraceOTLP != "": + fmt.Fprintf(w, "Tracing enabled (OpenTelemetry, service %q); spans -> OTLP %s\n", traceService, opts.TraceOTLP) + case runtimeLog != "": fmt.Fprintf(w, "Tracing enabled (OpenTelemetry, service %q); spans -> %s\n", traceService, runtimeLog) - } else { - fmt.Fprintf(w, "Tracing enabled (OpenTelemetry, service %q); spans go to the console only (pass --runtime-log to capture them)\n", traceService) + default: + fmt.Fprintf(w, "Tracing enabled (OpenTelemetry, service %q); spans go to the console only (pass --runtime-log to capture them, or --trace-otlp for a collector)\n", traceService) } } diff --git a/cmd/mxcli/docker/runlocal_test.go b/cmd/mxcli/docker/runlocal_test.go index 4d1345138..06eb1cd3b 100644 --- a/cmd/mxcli/docker/runlocal_test.go +++ b/cmd/mxcli/docker/runlocal_test.go @@ -134,7 +134,7 @@ func TestBuildRuntimeSettings(t *testing.T) { func TestWithTraceEnv(t *testing.T) { base := []string{"PATH=/bin", "JAVA_TOOL_OPTIONS=-Xmx512m"} - env := withTraceEnv(base, "/agents/otel.jar", "sudoku") + env := withTraceEnv(base, "/agents/otel.jar", "sudoku", "") get := func(key string) (string, bool) { for _, e := range env { @@ -166,10 +166,32 @@ func TestWithTraceEnv(t *testing.T) { } // A user-provided OTEL_* is respected (not overridden). - env = withTraceEnv([]string{"OTEL_TRACES_EXPORTER=otlp"}, "/agents/otel.jar", "svc") + env = withTraceEnv([]string{"OTEL_TRACES_EXPORTER=otlp"}, "/agents/otel.jar", "svc", "") if v, _ := get2(env, "OTEL_TRACES_EXPORTER"); v != "otlp" { t.Errorf("user OTEL_TRACES_EXPORTER should win, got %q", v) } + + // An OTLP endpoint switches the traces exporter to otlp and wires the + // protocol + endpoint (for flame charts). (sudoku tracing follow-up) + env = withTraceEnv([]string{"PATH=/bin"}, "/agents/otel.jar", "svc", "http://127.0.0.1:4318") + if v, _ := get2(env, "OTEL_TRACES_EXPORTER"); v != "otlp" { + t.Errorf("OTEL_TRACES_EXPORTER = %q, want otlp", v) + } + if v, _ := get2(env, "OTEL_EXPORTER_OTLP_PROTOCOL"); v != "http/protobuf" { + t.Errorf("OTEL_EXPORTER_OTLP_PROTOCOL = %q, want http/protobuf", v) + } + if v, _ := get2(env, "OTEL_EXPORTER_OTLP_ENDPOINT"); v != "http://127.0.0.1:4318" { + t.Errorf("OTEL_EXPORTER_OTLP_ENDPOINT = %q, want the endpoint", v) + } + + // A user-set exporter still wins even when an OTLP endpoint is passed. + env = withTraceEnv([]string{"OTEL_TRACES_EXPORTER=console"}, "/agents/otel.jar", "svc", "http://127.0.0.1:4318") + if v, _ := get2(env, "OTEL_TRACES_EXPORTER"); v != "console" { + t.Errorf("user OTEL_TRACES_EXPORTER should win over --trace-otlp, got %q", v) + } + if _, ok := get2(env, "OTEL_EXPORTER_OTLP_ENDPOINT"); ok { + t.Error("endpoint should not be set when the user pinned a non-otlp exporter") + } } func get2(env []string, key string) (string, bool) { diff --git a/docs-site/src/tools/run-local.md b/docs-site/src/tools/run-local.md index 49a106cd7..b52fc0c4d 100644 --- a/docs-site/src/tools/run-local.md +++ b/docs-site/src/tools/run-local.md @@ -83,6 +83,7 @@ so structural changes need a restart; behavioural changes do not. | `--metrics` | off | Register a Prometheus meter registry; metrics served at `http://127.0.0.1:/prometheus` | | `--trace` | off | Enable OpenTelemetry tracing (bundled agent → runtime log) with default span filters | | `--trace-service` | `.mpr` name | `OTEL_SERVICE_NAME` under `--trace` | +| `--trace-otlp` | off (console) | Export traces to this OTLP collector endpoint (e.g. `http://127.0.0.1:4318`) instead of the console; implies `--trace` (needed for flame charts) | | `--runtime-setting Key=Value` | — | Merge an extra runtime setting into the boot config (Value parsed as JSON when possible); repeatable | ## Metrics and OpenTelemetry @@ -113,8 +114,18 @@ mxcli run --local -p app.mpr --trace tail -f .mxcli/runtime.log # microflow spans: mx.microflow.name / mx.microflow.depth ``` -To export to a collector instead of the console, set the OTEL env yourself before -running (`--trace` won't override an exporter you've set): +The console exporter omits start/end timestamps and parent span IDs, so call trees +and durations can't be reconstructed from it — for flame charts, export to an OTLP +collector with `--trace-otlp ` (implies `--trace`; sets +`OTEL_TRACES_EXPORTER=otlp`, `OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf`, and the +endpoint for you): + +```bash +mxcli run --local -p app.mpr --trace-otlp http://127.0.0.1:4318 +``` + +You can still set the OTEL env yourself for full control (`--trace` / `--trace-otlp` +won't override an exporter you've set): ```bash export OTEL_TRACES_EXPORTER=otlp OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 From 9570619ecf2e6b1cd453dc76ee2e307c6963b837 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 07:04:07 +0000 Subject: [PATCH 2/4] fix(catalog): flag xpath_expressions as a FULL-only table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plain `refresh catalog` leaves the analytic tables empty (0 rows, no error); a query then silently returns nothing. `warnIfCatalogModeInsufficient` already warns for the FULL-only views, but `xpath_expressions` was missing from `fullOnlyTables` — so `SELECT … FROM CATALOG.XPATH_EXPRESSIONS` in fast mode returned empty with no hint (activities/refs were covered, but xpath was not). Add `xpath_expressions` to `fullOnlyTables` so a fast-mode query against it warns "requires refresh catalog full" like the sibling analytic tables. TestFullOnlyTablesCoverage guards that every FULL-gated view (activities/refs/xpath_expressions/widgets) is flagged and a fast-mode table (entities) is not. Reported from the sudoku "app warehouse" finding (#30) — the REFRESH-CATALOG-FULL silent-empty papercut. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4 --- mdl/executor/cmd_catalog.go | 11 ++++++----- mdl/executor/cmd_catalog_mode_test.go | 27 +++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) create mode 100644 mdl/executor/cmd_catalog_mode_test.go diff --git a/mdl/executor/cmd_catalog.go b/mdl/executor/cmd_catalog.go index a7db3458f..da55dac56 100644 --- a/mdl/executor/cmd_catalog.go +++ b/mdl/executor/cmd_catalog.go @@ -78,11 +78,12 @@ func execShowCatalogTables(ctx *ExecContext) error { // fullOnlyTables are catalog tables only populated by REFRESH CATALOG FULL. var fullOnlyTables = map[string]bool{ - "widgets": true, - "activities": true, - "refs": true, - "strings": true, - "permissions": true, + "widgets": true, + "activities": true, + "refs": true, + "xpath_expressions": true, + "strings": true, + "permissions": true, // graph_* analysis views read the refs graph, so they need full mode too. "graph_god_nodes": true, "graph_module_coupling": true, diff --git a/mdl/executor/cmd_catalog_mode_test.go b/mdl/executor/cmd_catalog_mode_test.go new file mode 100644 index 000000000..def55f9dc --- /dev/null +++ b/mdl/executor/cmd_catalog_mode_test.go @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 + +package executor + +import "testing" + +// TestFullOnlyTablesCoverage guards the "plain REFRESH leaves the analytic tables +// empty, 0 rows, no error" papercut: every catalog table populated only in FULL +// mode must be flagged as requiring `refresh catalog full`, so a query in fast +// mode warns instead of silently returning nothing. xpath_expressions was missing +// (activities/refs were covered) — a query against it returned empty with no hint. +func TestFullOnlyTablesCoverage(t *testing.T) { + // These views are populated only when the catalog is built in FULL mode + // (builder_microflows.go activities, builder_references.go refs, + // builder_xpath.go xpath_expressions, builder_pages.go widgets). + fullOnly := []string{"activities", "refs", "xpath_expressions", "widgets"} + for _, tbl := range fullOnly { + if got := tableRequiredMode(tbl); got != "refresh catalog full" { + t.Errorf("tableRequiredMode(%q) = %q, want %q — a fast-mode query would silently return 0 rows", tbl, got, "refresh catalog full") + } + } + + // A fast-mode table must NOT be flagged (no false warning). + if got := tableRequiredMode("entities"); got != "refresh catalog" { + t.Errorf("tableRequiredMode(\"entities\") = %q, want %q", got, "refresh catalog") + } +} From 3397baa0affac0dff73de0290859f8fbd5d3d4d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 07:05:36 +0000 Subject: [PATCH 3/4] docs(catalog): attach .mxcli/catalog.db directly + app-warehouse recipe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalog is a plain SQLite database at /.mxcli/catalog.db — exporting it to JSON first (as one might reach for) is strictly worse: it loses most of the tables, adds human-readable chatter, and goes stale. Document attaching it directly, plus an external DuckDB "app warehouse" recipe that joins the catalog to the app's Postgres and the OTLP trace dump read-only for cross-source questions no single source can answer (mxcli does not embed DuckDB — this is a dev-container recipe). Also flag, in both the use-cases page and the REFRESH CATALOG page, that FULL mode is what populates the analytic tables (activities/refs/ xpath_expressions/widgets) — plain refresh leaves them empty. Reported from the sudoku "app warehouse" finding (#30). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4 --- docs-site/src/tools/catalog-use-cases.md | 43 ++++++++++++++++++++++++ docs-site/src/tools/refresh-catalog.md | 6 ++++ 2 files changed, 49 insertions(+) diff --git a/docs-site/src/tools/catalog-use-cases.md b/docs-site/src/tools/catalog-use-cases.md index 5ebceabe0..f14fb1210 100644 --- a/docs-site/src/tools/catalog-use-cases.md +++ b/docs-site/src/tools/catalog-use-cases.md @@ -211,3 +211,46 @@ WHERE Name != UPPER(SUBSTR(Name, 1, 1)) || SUBSTR(Name, 2) OR Name LIKE '%_%' OR Name LIKE '% %'; ``` + +## Query the catalog directly (and join it to other sources) + +The catalog **is** a SQLite database — `/.mxcli/catalog.db`, written +next to the `.mpr` and refreshed by `refresh catalog`. You do not need to export +it to JSON (that drags in human-readable formatting, exposes only a subset of the +tables, and goes stale immediately). Attach the file directly with any SQLite- or +DuckDB-compatible tool for ad-hoc analysis beyond what `SELECT … FROM CATALOG.*` +exposes in one query. + +> **Run `refresh catalog full` first for the analytic tables.** Plain +> `refresh catalog` (fast mode) leaves `activities` / `refs` / `xpath_expressions` +> (and `widgets`) **empty** — a fast-mode query against them warns +> *"requires refresh catalog full"*. Full mode populates them (activities carry +> the model GUIDs the debugger uses, one row per activity with its sequence). + +```bash +# from a dev container, after: mxcli -p app.mpr -c "refresh catalog full" +duckdb -c "ATTACH '.mxcli/catalog.db' AS cat (TYPE sqlite, READ_ONLY); + SELECT MicroflowQualifiedName, COUNT(*) AS activities + FROM cat.activities_data GROUP BY 1 ORDER BY 2 DESC LIMIT 10;" +``` + +### Cross-source "app warehouse" (dev container) + +Because the catalog is a plain database, one engine can join it to the app's data +and its telemetry without any ETL — useful for questions no single source can +answer (e.g. runtime cost per microflow *joined to* model shape, or query time per +entity *joined to* live row counts). This is an **external** DuckDB recipe — mxcli +does not embed DuckDB — scoped to dev data in the dev container: + +```sql +-- model metadata (catalog) + app data (the local app Postgres), both read-only +ATTACH '.mxcli/catalog.db' AS cat (TYPE sqlite, READ_ONLY); +ATTACH 'dbname=app host=127.0.0.1 user=mendix' AS app (TYPE postgres, READ_ONLY); +-- traces exported by `mxcli run --local --trace-otlp …` (or a JSONL span dump) +SELECT * FROM read_json_auto('spans.jsonl') LIMIT 10; +``` + +Caveats: keep every attachment **read-only** (especially the app database — never +attach a production DB, and even locally make it an explicit choice), and note that +unfiltered per-activity tracing produces a very large span volume (~110k spans for +one busy transaction), so filter or sample before loading traces. diff --git a/docs-site/src/tools/refresh-catalog.md b/docs-site/src/tools/refresh-catalog.md index 8e886de95..d3bb6e0d2 100644 --- a/docs-site/src/tools/refresh-catalog.md +++ b/docs-site/src/tools/refresh-catalog.md @@ -77,3 +77,9 @@ The catalog is cached in `.mxcli/catalog.db` next to the MPR file. On subsequent - **Before cross-reference navigation** -- Run `REFRESH CATALOG FULL` to enable CALLERS/CALLEES/IMPACT - **After making changes** -- Refresh to update the catalog with new elements - **If results seem stale** -- Use `REFRESH CATALOG FULL FORCE` to force a rebuild + +> **FULL mode populates the analytic tables.** Plain `REFRESH CATALOG` (fast mode) +> leaves `CATALOG.ACTIVITIES`, `CATALOG.REFS`, `CATALOG.XPATH_EXPRESSIONS`, and +> `CATALOG.WIDGETS` **empty** — a fast-mode query against them warns *"requires +> refresh catalog full"*. Run `REFRESH CATALOG FULL` to fill them (activity rows +> carry the model GUIDs, cross-reference edges, and XPath expressions). From a9dfaba97298562ee10ed5776ee4cad6f71e9bce Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 07:29:05 +0000 Subject: [PATCH 4/4] docs(skill): add analyze-runtime skill (logs + metrics + traces + catalog) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Analyzing an app's runtime behavior is a recurring procedure spanning four signals — the runtime log, Prometheus metrics, OpenTelemetry traces, and the catalog (model shape) — so it belongs in a skill, not scattered across feature docs. Add `.claude/skills/mendix/analyze-runtime.md`: when to reach for each signal, how to collect it (`run --local` + `--metrics` / `--trace` / `--trace-otlp`, `refresh catalog full`), the cross-source "app warehouse" join (external DuckDB — mxcli does not embed it), the logs↔traces gap, and a decision guide mapping questions to tools. Registered in the skills README (Specialized Skills + loading guide); synced into user projects by `mxcli init`. Trim the docs-site catalog "app warehouse" block to the catalog-attach feature note plus a pointer to the skill, so the full procedure has a single canonical home. Follow-up to the sudoku "app warehouse" finding (#30). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JXnEgoc2NQP1Y2TWMCMXC4 --- .claude/skills/mendix/README.md | 3 + .claude/skills/mendix/analyze-runtime.md | 155 +++++++++++++++++++++++ docs-site/src/tools/catalog-use-cases.md | 29 ++--- 3 files changed, 167 insertions(+), 20 deletions(-) create mode 100644 .claude/skills/mendix/analyze-runtime.md diff --git a/.claude/skills/mendix/README.md b/.claude/skills/mendix/README.md index 55a972413..f15f4eee4 100644 --- a/.claude/skills/mendix/README.md +++ b/.claude/skills/mendix/README.md @@ -66,6 +66,7 @@ Page-specific patterns: | [create-custom-widget.md](create-custom-widget.md) | Custom pluggable widget AIGC | Creating custom React widgets from scratch | | [migrate-design-prototype.md](migrate-design-prototype.md) | Turn a Claude Design prototype into a themed Mendix app | Reproducing a design handoff/prototype as an SCSS theme + styled pages | | [debug-bson.md](debug-bson.md) | BSON debugging | Troubleshooting SDK issues | +| [analyze-runtime.md](analyze-runtime.md) | Analyze runtime behavior — logs, metrics, traces, catalog, and cross-source joins | Profiling a slow page/microflow, finding what hits the DB, correlating cost with model shape | --- @@ -97,6 +98,8 @@ Load skills based on the task: | "Build a pluggable widget" | `create-custom-widget.md` | | "Turn a design prototype/handoff into a Mendix app" | `migrate-design-prototype.md`, `theme-styling.md`, `create-page.md` | | "Build/apply a theme from a design" | `migrate-design-prototype.md`, `theme-styling.md` | +| "Why is this slow / profile the app / what hits the database" | `analyze-runtime.md`, `run-local.md` | +| "Trace / metrics / flame chart / correlate cost with model" | `analyze-runtime.md` | ### For Error Recovery diff --git a/.claude/skills/mendix/analyze-runtime.md b/.claude/skills/mendix/analyze-runtime.md new file mode 100644 index 000000000..b2f2397ea --- /dev/null +++ b/.claude/skills/mendix/analyze-runtime.md @@ -0,0 +1,155 @@ +# Analyze an App's Runtime Behavior — Logs, Metrics, Traces, Catalog + +## Overview + +When you need to understand what an app *actually does* at runtime — why a page is +slow, which microflow dominates cost, what hits the database, whether an error is +network or logic — the signals live in four places. This skill is the procedure for +collecting them and, crucially, **joining them**, because the useful questions cross +sources that no single tool answers alone. + +| Signal | Where | How you get it | +|--------|-------|----------------| +| **Logs** (server stack traces + your `LOG` output) | `/.mxcli/runtime.log` | `mxcli run --local` tees it automatically | +| **Metrics** (throughput, DB counts, sessions, queues) | `/prometheus` on the admin port | `mxcli run --local --metrics` | +| **Traces** (per-microflow / per-activity spans + timings) | console→`runtime.log`, or an OTLP collector | `mxcli run --local --trace` / `--trace-otlp` | +| **Model shape** (activities, complexity, refs, XPath) | `.mxcli/catalog.db` (SQLite) | `mxcli … "refresh catalog full"` then `SELECT … FROM CATALOG.*` | + +## When to Use This Skill + +- A page/microflow is slow and you need to find *where* the time goes. +- You want to know which entities/queries the app actually hits, and how often. +- A server-side error shows only a generic dialog in the browser. +- You're profiling and need a flame chart, or want cost correlated with model shape. + +Prerequisite: run the app with the fast local loop — see `run-local.md`. Everything +below assumes `mxcli run --local` (add the flags noted per signal). + +## 1. Logs — the first stop for errors + +`run --local` writes the runtime log to `/.mxcli/runtime.log` (override +`--runtime-log`, `-` disables). It carries JVM stdout/stderr **and** the application +log — server stack traces, your microflow/nanoflow `LOG` output, and the DB +synchronization counts at startup. + +```bash +mxcli run --local -p app.mpr +tail -f .mxcli/runtime.log +``` + +Gotchas: +- **Nanoflow `LOG` lands under the `Client_Nanoflow` node**, not the node name you + declared — a filter built around microflow node names silently drops it. `LOG DEBUG` + from a nanoflow is dropped server-side (browser console only). See `write-nanoflows.md`. +- A spike in "Executing N database synchronization command(s)" on an *unchanged* model + is a red flag (see the `create or modify` data-loss class of bug). + +## 2. Metrics — throughput and database pressure + +`--metrics` registers a Prometheus registry, served at +`http://127.0.0.1:/prometheus` (loopback). + +```bash +mxcli run --local -p app.mpr --metrics +curl -s http://127.0.0.1:8090/prometheus | grep -E 'connectionbus_|handler_requests|sessions_|taskqueue_' +``` + +Useful families: `connectionbus_{selects,inserts,updates,deletes,transactions}_total` +(database pressure), `handler_requests_total` (throughput), `sessions_*`, +`taskqueue_*` (background work). Merge any extra registry (otlp/influx/statsd) with +`--runtime-setting 'Metrics.Registries=[…]'`. + +## 3. Traces — where the time goes + +`--trace` attaches the bundled OpenTelemetry agent. **Default span filters ship with +it** (`OpenTelemetry._RuntimeSpanFilters`) because unfiltered per-activity tracing is +**~10× slower** and produces ~110k spans for one busy transaction — that's a +flow-*shape* debugging mode, not a timing mode. + +```bash +# console exporter → runtime.log (span names/attrs only) +mxcli run --local -p app.mpr --trace +# flame charts: export to a collector (console can't reconstruct call trees/durations) +mxcli run --local -p app.mpr --trace-otlp http://127.0.0.1:4318 +``` + +- The **console exporter omits start/end timestamps and parent span IDs** — you get + span names + attributes but no call tree and no durations. For real timing/flame + charts use `--trace-otlp ` (implies `--trace`), which sets the OTLP + exporter for you; user-set `OTEL_*` env still wins. +- `--trace-service NAME` sets `OTEL_SERVICE_NAME` (default the `.mpr` name); use + distinct names per app for multi-app correlation. Trace context (W3C `traceparent`) + crosses app boundaries automatically over `rest call`. +- To examine flow *shape* on a small flow, temporarily disable the filters with + `--runtime-setting 'OpenTelemetry._RuntimeSpanFilters=[]'`. + +## 4. Model shape — the catalog + +The catalog is a SQLite database at `.mxcli/catalog.db` describing the model. **Run +`refresh catalog full`** — plain `refresh catalog` (fast mode) leaves the analytic +tables (`CATALOG.ACTIVITIES`, `CATALOG.REFS`, `CATALOG.XPATH_EXPRESSIONS`, +`CATALOG.WIDGETS`) **empty** (a fast-mode query warns "requires refresh catalog full"). + +```bash +mxcli -p app.mpr -c "refresh catalog full" +mxcli -p app.mpr -c "SELECT MicroflowQualifiedName, COUNT(*) activities + FROM CATALOG.ACTIVITIES GROUP BY 1 ORDER BY 2 DESC LIMIT 10" +``` + +`CATALOG.ACTIVITIES.Id` is the model GUID the debugger breaks on (see +`debug-microflows.md`), with the action name and its sequence — a named, ordered +activity list per microflow. See `catalog-search.md` / `graph-analysis.md` for the +richer queries. + +## 5. The app warehouse — join the signals (external DuckDB) + +Each signal alone answers little; the useful questions cross them. Because the catalog +is a plain database and the app's dev data is Postgres, one engine can join model +shape + live data + telemetry with **no ETL**. mxcli does **not** embed DuckDB — this +is a dev-container recipe (dev data, dev telemetry, everything read-only): + +```sql +-- in duckdb, from the project dir, after `refresh catalog full` + a --trace-otlp run +ATTACH '.mxcli/catalog.db' AS cat (TYPE sqlite, READ_ONLY); +ATTACH 'dbname=app host=127.0.0.1 user=mendix' AS app (TYPE postgres, READ_ONLY); +CREATE VIEW spans AS SELECT * FROM read_json_auto('spans.jsonl'); +``` + +Two joins that are impossible in any single source: + +- **Runtime cost × model shape** — span durations per microflow joined to + `cat.activities_data` (count) and complexity. Complexity does *not* predict cost: a + 2-activity flow that delegates can cost more than a 14-activity one. A lint rule + can't see that; this join can. +- **Query time × entity × live rows** — span DB timings joined to `cat` (which entity) + and `app` (row counts). This is how you find that the task-queue poller + (`system$queuedtask`, owned by no microflow) is the app's largest DB consumer — + invisible from any per-microflow view. + +Caveats: keep every attachment **read-only** (never a production DB; even locally make +it explicit), and filter/sample traces first — unfiltered span volume is large. + +## Known gap: logs ↔ traces + +Runtime log lines carry **no trace id**, so joining logs to traces is a fuzzy +timestamp join (worst exactly under concurrency). The OTel agent populates the trace +id in the MDC, but Mendix's log pattern doesn't print it — closing the file-log side +needs an upstream `%X{trace_id}` change, not mxcli. For collector-side correlation, +export traces (and logs) via OTLP with `--trace-otlp` so the backend joins them. + +## Decision guide + +| Question | Reach for | +|----------|-----------| +| "Why did this error?" (server-side) | **Logs** (`runtime.log`) | +| "How much DB / throughput / queue work?" | **Metrics** (`--metrics`) | +| "Where does the time go in this flow?" | **Traces** (`--trace-otlp` for a flame chart) | +| "What's the flow's shape / activity list?" | **Catalog** (`refresh catalog full`) or the debugger | +| "Which cost belongs to which entity/model construct?" | **Warehouse** (catalog × spans × app DB) | + +## See Also + +- `run-local.md` — the local loop these flags hang off (`--metrics` / `--trace` / `--trace-otlp` / `--runtime-setting` reference). +- `debug-microflows.md` — interactive breakpoints/stepping when a trace isn't enough. +- `catalog-search.md`, `graph-analysis.md` — catalog query patterns and dependency analysis. +- `verify-with-oql.md` — query the running app's data directly. diff --git a/docs-site/src/tools/catalog-use-cases.md b/docs-site/src/tools/catalog-use-cases.md index f14fb1210..d989213e7 100644 --- a/docs-site/src/tools/catalog-use-cases.md +++ b/docs-site/src/tools/catalog-use-cases.md @@ -234,23 +234,12 @@ duckdb -c "ATTACH '.mxcli/catalog.db' AS cat (TYPE sqlite, READ_ONLY); FROM cat.activities_data GROUP BY 1 ORDER BY 2 DESC LIMIT 10;" ``` -### Cross-source "app warehouse" (dev container) - -Because the catalog is a plain database, one engine can join it to the app's data -and its telemetry without any ETL — useful for questions no single source can -answer (e.g. runtime cost per microflow *joined to* model shape, or query time per -entity *joined to* live row counts). This is an **external** DuckDB recipe — mxcli -does not embed DuckDB — scoped to dev data in the dev container: - -```sql --- model metadata (catalog) + app data (the local app Postgres), both read-only -ATTACH '.mxcli/catalog.db' AS cat (TYPE sqlite, READ_ONLY); -ATTACH 'dbname=app host=127.0.0.1 user=mendix' AS app (TYPE postgres, READ_ONLY); --- traces exported by `mxcli run --local --trace-otlp …` (or a JSONL span dump) -SELECT * FROM read_json_auto('spans.jsonl') LIMIT 10; -``` - -Caveats: keep every attachment **read-only** (especially the app database — never -attach a production DB, and even locally make it an explicit choice), and note that -unfiltered per-activity tracing produces a very large span volume (~110k spans for -one busy transaction), so filter or sample before loading traces. +### Cross-source "app warehouse" + +Because the catalog is a plain database, one engine (e.g. DuckDB — mxcli does not +embed it) can `ATTACH` it alongside the app's dev Postgres **read-only** and a trace +dump, joining model shape + live data + telemetry with no ETL to answer questions no +single source can (runtime cost × model shape, query time × entity × live rows). The +full logging/metrics/tracing/catalog analysis procedure — including this join — is the +**`analyze-runtime`** skill (`mxcli init` installs it into your project's +`.claude/skills/mendix/`).