Minimal C-facing provider library for Observer.
If you are new to this surface, start with HOWTO.md before reading the individual snippets and starters.
The best-DX path now steals shamelessly from Criterion:
Test("suite", "human title") { ... }TrackedTest("suite", "human title", "stable/id") { ... }observe_assert(...)observe_invariant(...)observe_assert_eq(...)observe_assert_neq(...)observe_assert_not_null(...)observe_assert_str_eq(...)
Compatibility aliases for the cr_* names currently remain available, but observe_* is now the primary surface.
Compatibility aliases for TestTitle(...), TestTitleId(...), and TestId(...) currently remain available, but the primary documented path is now string-based Test(...) plus optional TrackedTest(...).
observe_assert(...) and observe_invariant(...) are intentionally not identical in meaning:
observe_assert(...)expresses a claim about the behavior under testobserve_invariant(...)expresses a required condition of the test setup, harness state, or protocol assumptions
They currently share the same failure path in implementation, but they are separate semantic categories and should stay that way.
Underneath that familiar syntax, Observer still enforces its own rules:
- tests auto-register at process startup
- deterministic materialization lowers registered tests into explicit registrations
- registry validation rejects duplicates
- deterministic sorting is by canonical name, then target
- observation is bounded and opt-in
The intended build flag is -D__OBSERVE__.
With __OBSERVE__ defined:
- tests register automatically
- host helpers are active
OBSERVER_HOST_MAIN(...)and dispatch helpers servelist,run, andobserve
Without __OBSERVE__ defined:
- test declarations still compile as ordinary C code
- no tests are registered automatically
- host dispatch returns a disabled diagnostic if called
Projects that want stricter exclusion can still wrap their own integration points with #ifdef __OBSERVE__ or compile test translation units only in observe builds.
The library does not guess names or targets from C symbols or runtime reflection.
HOWTO.md: detailed user manual covering authoring,__OBSERVE__modes, host transport, inventory derivation, and end-to-end workflowobserver.h: public APIobserver.c: implementationexample_smoke.c: tiny compile-check examplehost_example.c: tinylist/runhost examplehost_main_example.c: explicitmain(argc, argv)handoff toobserver_host_main(...)host_mixed_example.c: app-owned CLI example that routes onlyobserve ...starter/: runnable project-shaped example with Makefile, provider host build, inventory derivation, suite run, and snapshot verificationstarter-failure/: runnable failing companion showing the same provider flow with one intentionally failing exported test
If you want the full end-to-end workflow rather than isolated snippets, start with starter/ and then compare it with starter-failure/.
#include "observer.h"
int sum(int a, int b) {
return a + b;
}
Test("math", "adds two numbers") {
observe_assert_eq(sum(2, 3), 5);
}
TrackedTest("math", "handles negatives", "math/handles-negatives") {
observe_assert_eq(sum(-2, 1), -1);
}That authored form auto-registers, then materializes deterministically into explicit provider registrations before list or run consumes them.
If a test needs a refactor-stable identity, it can opt into id explicitly:
TrackedTest("math", "adds two numbers", "math/adds-two-numbers") {
observe_assert_eq(sum(2, 3), 5);
}If a test wants to emit observational data, it uses the existing context helpers directly:
Test(api, healthcheck) {
static const double latency[] = {1000.0, 1100.0, 980.0};
observe_metric("wall_time_ns", 104233.0);
observe_vector("request_latency_ns", latency, 3u);
observe_tag("resource_path", "fixtures/config.json");
observe_invariant(client != NULL);
observe_assert(status_code == 200);
}- derived suite/title identity must be non-empty unless explicit
idis provided - explicit
id, when present, must be non-empty - function must be non-null
- resolved canonical names must be unique
- resolved targets must be unique
In this first cut, the resolved identity is used for both canonical name and target.
The default derived identity is suite :: title. Duplicate suite/title pairs are hard errors.
This means the common C path is now aligned with the TypeScript shape at the semantic level:
- human title first
- optional stable
id - deterministic lowering before host exposure
cc -D__OBSERVE__ -std=c11 -Wall -Wextra -pedantic lib/c/example_smoke.c lib/c/observer.c -o /tmp/observer-c-smoke
/tmp/observer-c-smokeThe library now owns the standard provider host transport, including:
listcommand handlingruncommand handling- branded
observealias handling for the same execution path - JSON emission
- base64 encoding
- target lookup and registry materialization
That means a direct provider host binary can stay nearly trivial:
#include "observer.h"
TrackedTest("pkg", "smoke test", "pkg::smoke") {
static const char out[] = "ok\n";
write_stdout(out, sizeof(out) - 1u);
observe_invariant(1);
}
OBSERVER_HOST_MAIN("c")cc -D__OBSERVE__ -std=c11 -Wall -Wextra -pedantic lib/c/host_example.c lib/c/observer.c -o /tmp/observer-c-host
/tmp/observer-c-host list
/tmp/observer-c-host observe --target pkg::smoke --timeout-ms 1000
/tmp/observer-c-host run --target pkg::smoke --timeout-ms 1000If you do not want the macro-owned entrypoint, the same direct-host behavior can be written explicitly in host_main_example.c:
int main(int argc, char **argv) {
#if defined(__OBSERVE__)
return observer_host_main("c", argc, argv);
#else
(void)argc;
(void)argv;
return 2;
#endif
}For developer-facing usage, prefer observe. run remains the canonical provider protocol subcommand used by Observer itself for compatibility with the standardized outer contract.
If a project wants to keep its own main() and only route Observer commands when the observe build flag is enabled, it can do that too:
#include "observer.h"
#include <stdio.h>
TrackedTest("pkg", "embedded smoke test", "pkg::embedded-smoke") {
static const char out[] = "ok\n";
write_stdout(out, sizeof(out) - 1u);
observe_invariant(1);
}
static int app_main(int argc, char **argv) {
(void)argc;
(void)argv;
fputs("app main\n", stdout);
return 0;
}
int main(int argc, char **argv) {
#if defined(__OBSERVE__)
if (argc > 1 && strcmp(argv[1], "observe") == 0) {
return observer_host_dispatch_embedded("c", "observe", argc, argv);
}
#endif
return app_main(argc, argv);
}Compile that path with:
cc -D__OBSERVE__ -std=c11 -Wall -Wextra -pedantic lib/c/host_embed_example.c lib/c/observer.c -o /tmp/observer-c-embed
/tmp/observer-c-embed observe list
/tmp/observer-c-embed observe --target pkg::embedded-smoke --timeout-ms 1000This embedded form is the preferred story when an application already uses subcommands like run for its own CLI. The top-level app command becomes observe, and the Observer library handles the rest.
If you want a clearer mixed-argv example where the app still owns its own commands and hands off only observe, see host_mixed_example.c.
One common mistake is to copy the lowest-friction starter shape and then restate expect exit = 0 one exact test at a time.
If all you mean is "everything under this selector should pass", do not memcopy exact-name items.
Use one selector-level suite item instead:
test prefix: "glc/" timeoutMs: 1000: expect exit = 0.
If you want the same idea in the full surface, use one inventory-driven iterator instead:
(prefix: "glc/") forEach: [ :name |
(run: name timeoutMs: 1000) ifOk: [ :r |
expect: (r exit) = 0.
] ifFail: [ :f |
expect: Fail msg: "inventory run failed".
].
].
Only add exact-name items when they assert something extra, such as stdout, stderr, telemetry, or a different timeout.