C++ error reporting client for a ForgeOps instance. Requires a POSIX platform (macOS, Linux): see "Platform" below. Targets C++17.
Built around a live delivery queue (Configuration, EventBuilder, DeliveryQueue, Reporter, Client) rather than a disk-backed crash store. C++ has real exceptions, so "catch it, build a payload, hand it to a background thread" is both possible and the shape this SDK takes: an exception is caught at a normal call site, not recovered from a terminating process, so there's no need to persist it to disk before it can be delivered.
There's no package registry for C++ the way npm/PyPI/etc. work for other languages: CMake's own
FetchContent, pointed at a real tagged release, is the closest equivalent:
# your own CMakeLists.txt
include(FetchContent)
FetchContent_Declare(
forge_ops_tracker
GIT_REPOSITORY https://github.com/Luke-Popwell/forge-ops-tracker-cpp.git
GIT_TAG v0.1.0
)
FetchContent_MakeAvailable(forge_ops_tracker)
target_link_libraries(your_app PRIVATE forge_ops_tracker)That's a mirror, kept in sync automatically from sdks/cpp in the main forge_ops repo (which is
private, so isn't itself something FetchContent could ever pull directly): develop against
that repo, not this one. To build and run this SDK's own tests directly instead:
cd sdks/cpp
cmake -B build
cmake --build build
./build/tests/forge_ops_tracker_tests
# or: cd build && ctest --output-on-failureTwo real dependencies, not one:
- libcurl, for HTTP delivery: there's no HTTP client anywhere in the C++ standard library, and hand-rolling raw HTTP/1.1-over-TLS from a bare socket is a security-sensitive undertaking no reasonable client should attempt from scratch.
- nlohmann/json, for the event payload's JSON encoding.
That second one is worth being honest about rather than glossing over: C++ has no JSON type of its
own, and a small hand-rolled encoder is a viable option for that gap, but that isn't the path this
client takes. <nlohmann/json.hpp> is included directly in five of its seven public headers
(event_builder.hpp, client.hpp, delivery_queue.hpp, reporter.hpp, pii_scrubber.hpp), and
nlohmann::json is the type every one of those headers' own public signatures is built around, not
an implementation detail hidden behind a .cpp file. Ripping it out and hand-rolling a JSON
encoder instead would mean rewriting the public API of every header in this library. nlohmann/json
is header-only, extremely widely used, and MIT-licensed, which makes it a reasonable dependency to
keep rather than a red flag, but it is a second real dependency beyond libcurl, and this SDK's own
CMakeLists.txt and this README say so plainly. CMakeLists.txt looks for an already-installed
nlohmann_json first (via find_package, which it ships a working CMake config for almost
everywhere it's packaged, including Homebrew) and only falls back to fetching it itself via
FetchContent if that's not found.
POSIX only (confirmed by actually building and running the full test suite on macOS/AppleClang;
Linux/glibc is expected to behave identically: <execinfo.h>, <regex>, std::thread, and
libcurl are all standard there too, but that expectation is not independently verified the way
the macOS build is, since no Linux machine was available in this environment; the one place this
genuinely could differ is backtrace_symbols()'s exact per-frame text format, which event_builder.cpp's
own frame-parsing regex assumes is the same de facto convention on both platforms). Not Windows: no
<execinfo.h>, no <mach-o/dyld.h>, no POSIX signal handling.
Set a DSN (from a project's settings page in ForgeOps) explicitly: there's no FORGE_OPS_DSN
environment variable read automatically anywhere in this source; confirmed directly (grep) rather
than assumed. If your deployment wants that behavior, read std::getenv("FORGE_OPS_DSN") yourself
before calling init:
#include <forge_ops_tracker/forge_ops_tracker.hpp>
forge_ops_tracker::init([](forge_ops_tracker::Configuration& config) {
config.dsn = "https://<api_key>@your-forgeops-host/api/v1/events";
config.environment = "production";
config.release = "1.4.2";
config.app_root = "/opt/myapp"; // used for in_app backtrace classification, see below
});init() returns a mutable Configuration& you can also hold onto and mutate later. Call it once
at startup, before installing the terminate handler or reporting anything.
Report an exception you've already caught: the common case, and the one every convenience overload below is built around:
try {
do_something_risky();
} catch (const std::exception& e) {
forge_ops_tracker::capture_exception(e, {{"order_id", "42"}}); // context is any nlohmann::json object
}A non-std::exception throw (legal in C++: any type at all can be thrown) is still reportable,
just with no class name or message available, via std::exception_ptr:
try {
do_something_risky();
} catch (...) {
forge_ops_tracker::capture_exception(std::current_exception());
}A genuinely uncaught exception needs no further wiring at all, once
forge_ops_tracker::install_terminate_handler() has run at startup. std::terminate() is where
C++ ends up when an exception propagates past every handler: this installs a handler there that
reports whatever's in flight (via std::current_exception()) and then chains to whatever handler
was previously installed (the default one, unless something else in your process also called
std::set_terminate), so the process still terminates exactly as it would have without this
client, just with a report sent first.
You don't have to use the package-level init/capture_exception singleton at all: every piece
(Configuration, EventBuilder, DeliveryQueue, Client, Reporter) is a real, independently
constructible class, for anyone who wants multiple independently configured trackers in one process
instead of one shared global.
forge_ops_tracker::capture_exception(e, {{"order_id", "42"}}, {{"id", user.id}, {"email", user.email}});Or set_user to attach it to every subsequently reported exception on this thread (an explicit
capture_exception call with no user argument, or whatever the installed terminate handler
reports) until changed or cleared, rather than passing it to every call by hand, e.g. right after
authenticating a request:
forge_ops_tracker::set_user({{"id", user.id}, {"email", user.email}});
// once the request is done, or on sign-out:
forge_ops_tracker::set_user({});There's no way to automatically detect "the current user" the way a server-side web framework with
its own session/auth middleware can, so this is always manual. set_user is a plain thread_local,
not a process-wide global: the right choice for a server handling more than one request at a time,
each on its own thread, the same reasoning gems/forge_ops_tracker documents for its own
Thread.current use; a single-threaded program just has the one thread's worth of state, so this
still behaves like a plain global there. user can be any nlohmann::json object; there's no fixed
key list enforced, follow whatever shape your own app uses. Shows up on an issue's own detail page,
and as its own affected-users count alongside the regular event count.
A small, bounded trail of recent events attached to whatever gets reported next, so an issue's detail page can show what led up to it, not just the moment it happened:
forge_ops_tracker::add_breadcrumb("charging card", "payment", "info", {{"order_id", order.id}});category and level default to "custom"/"info"; data is any small nlohmann::json object.
Only the 30 most recent (Configuration::max_breadcrumbs) are kept, oldest dropped first; turn it
off with Configuration::track_breadcrumbs = false. message and data are PII-scrubbed like the
rest of the payload; category, level, and timestamp are structured values and never touched.
Omitted from the payload entirely when the trail is empty.
There's no web framework integration in this client to record one from automatically, so every
breadcrumb here is one you add by hand. Like set_user, the trail is a plain thread_local: right
for a server that handles one request per thread, but a thread that serves several units of work in
a row (a thread pool worker, say) must call forge_ops_tracker::clear_breadcrumbs() itself at the
start of each one, or the previous one's trail carries over. This client has no middleware to do
that for you.
Times whatever you wrap and reports one small aggregate per transaction (how many times it ran,
total and maximum duration) every Configuration::performance_flush_interval (60s by default), for
the Performance page's per-transaction table. Not one network call per timed call.
Each aggregate also carries a small latency histogram (a count per fixed latency bucket: 50, 100, 250, 500, 1000, 2500, 5000 and 10000ms, plus an overflow bucket), so ForgeOps can show an approximate p50/p95/p99 per transaction, not just an average. Percentiles are accurate to the width of whichever bucket a duration falls into; the SDK never stores the individual durations.
// RAII: records how long the scope lived, even if it throws.
{
forge_ops_tracker::ScopedTransaction timing("GET /users/:id");
handle_request(request);
}
// Or wrap a callable, and keep its return value:
auto user = forge_ops_tracker::time_transaction("load-user", [&] { return load_user(id); });
// Or record a duration you measured yourself, in milliseconds:
forge_ops_tracker::record_performance("nightly-export", elapsed_ms);This client has no web framework integration, so nothing is timed automatically: you choose
what to wrap. Keep transaction names low-cardinality ("GET /users/:id", not "GET /users/42"):
every distinct name is its own row. ScopedTransaction's destructor runs during stack unwinding,
so a scope that throws is still timed and the exception propagates unchanged. Turn it off with
track_performance = false; it also does nothing (and starts no thread) when reporting isn't
enabled for the current environment.
The first recorded duration starts one background std::thread that flushes on the interval (the
same lazy start DeliveryQueue uses), waiting on a condition variable rather than sleeping so it
stops promptly. What's left is flushed when the process exits normally (the flusher's destructor,
the C++ equivalent of the Ruby gem's at_exit); a process that exits some other way (a fatal
signal, _exit) loses the last window. forge_ops_tracker::flush_performance() sends it right now.
A failed delivery keeps every tally, so the next flush's window just grows. What a flush delivered
is subtracted from the tallies afterward, never the whole map cleared: a record_performance call
from another thread that lands while the network call is in flight (the lock is deliberately
released around it) would otherwise be silently discarded, a real bug sdks/go had and fixed and
that gems/forge_ops_tracker's reference implementation still has. A deterministic test pins this.
A slow call's own breakdown: which database calls, HTTP calls, or pieces of your code the time went
to, shown as a span tree on ForgeOps. Wrap the unit of work in a ScopedTrace, and anything inside
it, on the same thread, can add spans; the trace is sent only when the whole thing took at least
Configuration::trace_capture_threshold (1 second by default), so fast calls cost nothing on the
wire. Traces are per service; nothing is propagated across services.
{
forge_ops_tracker::ScopedTrace trace("GET /checkout");
forge_ops_tracker::ScopedSpan load("load order", "database", {{"order_id", 42}});
auto order = repo.find(42);
forge_ops_tracker::span("charge card", "service", [&] { return gateway.charge(order); });
// Something you timed yourself (kind is one of controller/service/database/redis/http/job/other):
forge_ops_tracker::record_span("SELECT orders", "database", started_at, duration_ms);
}kind outside that list is sent as other, since the server rejects a whole trace over one unknown
kind. This client has no web framework integration, so nothing starts a trace or records a span
automatically: you wrap what you want traced. Both RAII types record from their destructor, so a
scope that throws is still recorded and sent. A ScopedSpan nests under whichever span is open on
the same thread and does nothing outside a trace; a ScopedTrace inside an open trace records a span
instead of starting a second trace. The open trace is a thread_local, like the breadcrumb trail, so
it belongs to the thread that started it. A trace holds at most 500 spans.
Delivery runs on one background std::thread fed by a bounded queue (Configuration::queue_size),
started on the first finished slow trace and drained at normal exit; a full queue drops the trace
rather than blocking the caller. Turn the feature off with track_tracing = false.
Two explicit calls (nothing is automatic, so there is no track_metrics flag): a business event you
name yourself, and a reading from one of your own hosts.
forge_ops_tracker::capture_metric("signup"); // value defaults to 1.0: a bare counter
forge_ops_tracker::capture_metric("payment", 49.0); // a real magnitude; it may be negative (a refund)
forge_ops_tracker::capture_infrastructure_metric("cpu", 0.42); // hostname defaults to server_name
forge_ops_tracker::capture_infrastructure_metric("disk", 0.81, "db-1");
forge_ops_tracker::flush_metrics(); // send right nowEach capture is buffered and flushed as one batch every Configuration::metric_flush_interval /
infrastructure_metric_flush_interval (60 seconds by default) on a std::thread started at the first
capture, and once more when the process exits normally (the buffers are globals, and a global's
destructor runs at exit), so a short-lived cron program that captures a few readings and returns from
main needs nothing more; call flush_metrics() if it might exit another way (std::_Exit, a signal).
Every entry is stored as it was captured (a signup is a row, not a running total), so a count or sum
you compute later is exact. Both are a no-op when reporting isn't enabled for the environment.
A failed delivery keeps every entry for the next flush, and an entry captured while a delivery is in
flight is kept too (the Ruby gem's own buffer loses it; a test pins this with a hook that captures at
exactly that moment). Each buffer holds at most 1000 entries and drops further ones until a flush
succeeds, since a plan without the feature rejects every flush and would otherwise grow it for as long
as the process lives. A NaN or infinite value is dropped at capture: nlohmann::json serializes it as
null, which the server would reject along with the whole batch behind it. Requires a ForgeOps plan
that includes custom metrics / infrastructure monitoring.
Every report is pushed onto a DeliveryQueue (bounded by Configuration::queue_size, default
1000) and handed off to a single background std::thread that drains it one item at a time over
HTTP via Client (libcurl). push() itself never blocks the calling thread waiting on a network
call (it only briefly holds a mutex to enqueue) so reporting an error from a request-handling
thread can't stall that thread on a slow or unreachable tracker. If the queue is already at
capacity when push() is called (the background worker is behind, typically because delivery is
slow or the tracker is unreachable), the new event is dropped and logged rather than blocking or
growing unbounded; Configuration::logger, if set, is called with a message describing the drop.
The worker thread is started lazily, on the first push(), not at construction. A C++ binary has
no interpreter-level "module load" moment a prefork server could fork after, so there's no
fork-safety concern driving this; it's simply so a Reporter that's constructed but never actually
used to report anything never spins up a thread it doesn't need.
Destroying a DeliveryQueue (its destructor) signals the worker to stop and joins it, but the
worker's own loop keeps draining whatever's still queued before actually returning, rather than
abandoning it mid-queue. That means destruction can block for as long as delivering the remaining
items takes (up to Configuration::timeout_seconds each, if the tracker is slow or unreachable):
a real, worth-knowing behavior, not a bug: it trades "might block briefly at shutdown" for "don't
silently drop everything still in flight when the process is tearing down normally." Confirmed
directly by a real test (delivery_queue_destructor_drains_pending_items_before_returning), not
just read off the source.
backtrace()/backtrace_symbols() (<execinfo.h>) give a binary image name (closest available
analog to "file") and a resolved, demangled symbol (closest analog to "method"); line is always
JSON null: a compiled, optimized C++ binary has no source file/line information left in it at
runtime.
The backtrace is captured at the moment build() (or capture_exception/report) is called,
not at the moment the exception was originally thrown: C++ exceptions carry no stack trace of
their own. Confirmed directly on this machine: by the time a catch block runs, the stack has
typically already unwound back up through most of the intervening call frames, so a real backtrace
captured from inside a catch block is usually much shorter than the call stack that actually led
to the throw. Call capture_exception/report as close to the catch site as possible for the
most useful trace.
in_app compares a frame's image name against the running process's own executable name
(_NSGetExecutablePath on macOS, via <mach-o/dyld.h>): true only for frames inside your own
binary, false for anything from a shared library. Since this test suite links the library
statically into its own test binary, every frame is technically "your own binary" in that build:
a real caveat for any consumer who does the same (link statically rather than against a .so/
.dylib), documented here rather than silently left as a surprise: in_app degrades to "always
true" when the library and the app share one binary image, the same limitation is_in_app's own
comment in event_builder.cpp already flags.
Configuration::capture_source_context exists (defaulting to true, the same default every other
SDK in this repo uses) purely for API-shape consistency: a host app configuring this client sees
the same option every other SDK has. It does nothing here. Every other SDK in this repo that
supports it reads a few lines of source off disk around an in-app frame's culprit line at
capture-time, keyed off that frame's own file path and line number, but a backtrace frame here
never carries a real file path or line number at all (see "Backtrace frames" above: line is
always JSON null, since a compiled, optimized C++ binary has no source location left in it at
runtime). EventBuilder::attach_source_context is a documented no-op rather than a partial
implementation of something that can never actually run: there is no case, on this client's
capture path, where a real file+line pair exists to read.
PII pattern matching uses std::regex's default ECMAScript grammar, which accepts all 8 patterns
(including \b word boundaries) as written; verified directly against real matching input for
each pattern (see the test file's own pii_* tests), not assumed to translate cleanly just
because the syntax looks familiar.
The message, backtrace, and any context you attach are scanned for likely personal data: email
addresses, formatted SSNs/credit cards, known API key/token formats (AWS, Stripe, GitHub, JWT,
bearer tokens), and anything under a suspiciously-named key (password, api_key, ssn, and
similar, matched case- and punctuation-insensitively) and redacted before the payload ever
leaves this process. ForgeOps itself scrubs again on arrival regardless, so this is a second,
earlier layer, not the only one. Deliberately does not support Project#additional_sensitive_keys
(server-side only, by design: see pii_scrubber.hpp's own header comment). The user attached via
capture_exception's user argument or set_user above is a deliberate exception: it's never
scrubbed, since redacting it would defeat the whole point of identifying users in the first place.
To disable it: config.scrub_pii = false;
cd sdks/cpp
cmake -B build
cmake --build build
./build/tests/forge_ops_tracker_tests
# or: cd build && ctest --output-on-failureA hand-rolled test runner (TEST/RUN/ASSERT_TRUE macros), not an external framework like
Catch2 or GoogleTest: a real decision, not an oversight. Reaching for a vendored test framework
purely for the tests themselves, on top of the two real runtime dependencies this library already
has (libcurl, nlohmann_json: see "Dependencies" above), would add a third dependency for no
capability the ~80 lines of macros at the top of the test file don't already provide. C++
exceptions make the hand-rolled approach straightforward here: RUN() wraps each test in a
try/catch so an assertion failure (or any unexpected exception) fails just that one test and
moves on.
The test suite covers every component with real assertions, including real local HTTP delivery
(a small TestServer helper spins up a background-thread HTTP server on 127.0.0.1, reading the
full request before responding to avoid racing curl's own write) and a real bounded-queue drop
test against a deliberately non-responding "black hole" listener. All 34 tests pass under both a
plain build and a build with AddressSanitizer + UndefinedBehaviorSanitizer
(-fsanitize=address,undefined): zero sanitizer findings.
The terminate handler's own body isn't exercised by this test suite beyond confirming installation is idempotent: deliberately: actually letting an exception escape uncaught to trigger it for real would terminate the test process itself.
Both convenience overloads that accept an already-caught exception by reference
(EventBuilder::build(const std::exception&, ...) and Reporter::report(const std::exception&, ...)) originally rebuilt an exception_ptr via std::make_exception_ptr(exception). That looks
correct but silently slices: template argument deduction for make_exception_ptr picks its
template parameter from exception's declared parameter type (const std::exception&), not its
real dynamic type, so a caught custom exception (say, a std::runtime_error subclass with its own
message) came back through the rebuilt exception_ptr as a bare std::exception: both its real
class name and its own what() message replaced with std::exception's generic boilerplate text.
Verified directly with a standalone repro before touching the fix (a derived exception's message
came back as the literal string "std::exception", not the message it was actually constructed
with), then caught again by this SDK's own event_builder_basic_fields and
event_builder_scrubs_message_and_context_by_default tests, which failed against the unfixed
source with exactly that symptom before the fix below was applied.
The fix: both functions now prefer std::current_exception(), which has no such problem: it
reflects whatever's genuinely in flight, and calling it from inside the same catch block that
caught the exception (even through another function call, like capture_exception calling into
Reporter::report) still counts as "being handled" per the standard. Both fall back to the
original make_exception_ptr(exception) behavior only when nothing is actually in flight (a caller
building/reporting a std::exception object it never threw): that edge case genuinely can't
recover a derived type through a plain base-class reference; no implementation can work around
that, it's a hard C++ limitation rather than a bug in this one. See the comments on both fixed
functions (event_builder.cpp, reporter.cpp) for the full reasoning inline.
This means anyone who had already built against the original unfixed source and relied on
capture_exception(e)/report(e) from inside a real catch (const std::exception& e) block was
silently getting "std::exception" as both the exception class and the message for every report
sent that way: worth knowing if this SDK is ever pulled from a point before this fix.