PurpleOTel SDK
The complete OpenTelemetry SDK for Dart and Flutter — traces, logs, metrics, OTLP export, W3C propagation, distributed context, and auto-instrumentation — all in one package.
Zero global state. Zero generated protobuf files. Zero code generation step. Zero name collisions.
Why PurpleOTel?
The Problem
Observability in Dart and Flutter is broken. Today, if you want OpenTelemetry in your Dart app, you have exactly one option: dartastic_opentelemetry. And it's not ready for production.
dartastic_opentelemetry — the status quo is unacceptable:
- Unstable beta API. At
v1.1.0-beta.6, the public API is a moving target. Every update risks breaking your observability code. - Global mutable state.
OTel.initialize()stores configuration in global statics, making testing painful and breaking isolation between libraries. - Name collisions with
purple_logger. Both packages export aLoggerProviderclass — compile-time ambiguity every time. - Delegation pattern overhead. Every concept is wrapped in a delegate layer (
interface+_delegateimpl), adding indirection with no benefit. - 75+ generated
.pb.dartfiles. Protobuf schemas compiled into thousands of lines bloating your bundle, slowing compilation, and inflatingpub.devpage loads. - gRPC unavailable on web. The SDK defaults to gRPC export which does not work in browsers. Flutter Web users cannot send telemetry.
- Zero conformance tests. No tests validating behavior against the OpenTelemetry specification — you are trusting an unverified black box.
- Sparse, outdated docs. Minimal README, incomplete API docs, months of unanswered issues.
- Uncertain CNCF donation. Announced but no timeline or status update in over a year.
This is not a foundation you can bet your production observability on.
The Solution
PurpleOTel is the OpenTelemetry SDK Dart and Flutter deserve.
| What you get | What it means |
|---|---|
| Stable API with semantic versioning | Upgrade with confidence. Breaking changes are announced, documented, and gated behind major version bumps. |
| Zero global state | Everything is wired through dependency injection. Create isolated instances for testing, multi-tenant, or library isolation. |
| Zero name collisions | No LoggerProvider conflict with purple_logger. Use both packages side by side without a single hide or as directive. |
| No delegation pattern | Clean, direct architecture. One class, one responsibility. No interface-implementation pair for every concept. |
| Manual protobuf encoder (~500 lines) | Instead of 75+ generated .pb.dart files weighing ~3MB, PurpleOTel ships a hand-written, zero-dependency protobuf encoder. |
| Web-first transport | HTTP/protobuf is the default export path. Works on all 6 platforms — including Web. |
| Full OTel spec compliance | W3C TraceContext, SpanLimits, View API, cardinality limits. Backed by conformance tests validating behavior against the spec. |
| Complete auto-instrumentation | HTTP, Dio, Flutter Navigator instrumentation ship in the box. One line to wire up — everything traced automatically. |
Comparison
| Feature | dartastic_opentelemetry | PurpleOTel |
|---|---|---|
| API stability | ❌ Beta (1.1.0-beta.6) |
✅ Stable, semver |
| Global state required | ❌ OTel.initialize() |
✅ Zero global state |
| Name collisions | ❌ LoggerProvider conflict |
✅ No collisions |
| Delegation pattern | ❌ Interface/impl for everything | ✅ Clean, direct architecture |
| Bundle size | ❌ 75+ generated .pb.dart |
✅ ~500-line manual encoder |
| Web (HTTP export) | ❌ gRPC-dependent | ✅ HTTP/protobuf out of the box |
| Conformance tested | ❌ None | ✅ Validated against OTel spec |
| Documentation | ❌ Sparse, outdated | ✅ Full API docs + guides |
| Auto-instrumentation | ❌ Minimal | ✅ HTTP, Dio, Flutter nav |
| EventLogger pattern | ❌ Not supported | ✅ Native integration |
| ContextStorage via DI | ❌ Global only | ✅ Injected, swappable |
| Generics / DRY internals | ❌ Repetitive per-signal code | ✅ Generic, type-safe internals |
By the Numbers
Packages → 7 packages in the monorepo, all tested together
Tests → 145 tests, 0 failures
Signals → 3 (Logs + Traces + Metrics), all functional
Samplers → 4 built-in samplers
Propagation → W3C TraceContext, fully compliant
Export → OTLP over HTTP/protobuf (no gRPC required)
Protobuf code → ~500 lines, hand-written, zero dependencies
vs competition → ~3MB of generated .pb.dart files (75+ files)
Enterprise → 5 features: file rotation, env config, enrichers,
runtime reconfiguration, OTel bridge
Platforms → 6 (Android, iOS, Linux, macOS, Windows, Web)
The bottom line: PurpleOTel is the production-grade, spec-compliant, web-ready OpenTelemetry SDK the Dart/Flutter ecosystem has been waiting for.
Quick Start (30 Seconds)
import 'package:purple_otel_sdk/purple_otel_sdk.dart';
void main() {
final provider = SDKTracerProvider(
resource: Resource.empty,
processors: [SimpleSpanProcessor(const ConsoleSpanExporter())],
);
final tracer = provider.get('my-app');
final span = tracer.startSpan('fetch-user');
span.setAttribute('user.id', AttributeValue.string('42'));
span.end();
provider.shutdown();
}
What just happened: A span named fetch-user was created with a unique traceId and spanId, its attributes were captured, and on end() it flowed through the processor into the console exporter:
[SPAN] fetch-user kind=internal status=unset duration=0:00:00.000001
trace=3fa85f64... span=2d4c6e1a...
attrs: {user.id: 42}
5-Minute Setup (Production)
import 'package:purple_otel_sdk/purple_otel_sdk.dart';
Future<void> main() async {
final resource = Resource(Attributes.fromMap({
'service.name': 'checkout-service',
'service.version': '2.1.3',
'deployment.environment': 'production',
}));
final provider = SDKTracerProvider(
resource: resource,
processors: [
BatchSpanProcessor(
OtlpHttpSpanExporter(endpoint: Uri.parse('http://localhost:4318')),
config: const BatchConfig(
maxExportBatchSize: 512,
maxQueueSize: 2048,
scheduleDelay: Duration(seconds: 5),
),
),
],
);
final tracer = provider.get('checkout-service');
final span = tracer.startSpan('POST /checkout', kind: SpanKind.server);
span.setAttributes(Attributes.of({
'http.method': AttributeValue.string('POST'),
'http.route': AttributeValue.string('/checkout'),
'order.id': AttributeValue.string('ord-9973'),
}));
try {
await processPayment();
span.setStatus(SpanStatus.ok);
} catch (e, st) {
span.setStatus(SpanStatus.error('Payment declined'));
span.recordException(e, stackTrace: st);
}
span.end();
await provider.forceFlush();
await provider.shutdown();
}
Data flow: Span.end() → BatchSpanProcessor (buffers 512 spans) → OtlpHttpSpanExporter (protobuf) → POST /v1/traces → Collector → Jaeger/Tempo/Grafana
Distributed Tracing
Parent → Child Spans
The SDK uses Dart Zone-based context propagation. A child span automatically inherits its parent's traceId:
final parent = tracer.startSpan('handle-request', kind: SpanKind.server);
final child = tracer.startSpan('query-database', kind: SpanKind.client);
// child.spanContext.traceId == parent.spanContext.traceId
child.end();
parent.end();
Explicit Context Propagation
final span = tracer.startSpan('async-operation');
final ctx = Context.root.withValue(spanContextKey, span);
ZoneContextStorage.runWithContext(ctx, () {
final inner = tracer.startSpan('inner-work');
inner.end();
});
span.end();
W3C TraceContext — Inject
final span = tracer.startSpan('call-downstream');
final ctx = Context.root.withValue(spanContextKey, span);
final headers = <String, String>{};
W3CTraceContextPropagator.inject(ctx, headers);
// headers: {traceparent: 00-<traceId>-<spanId>-01}
await http.get(Uri.parse('https://downstream/api'), headers: headers);
span.end();
W3C TraceContext — Extract
final incoming = {'traceparent': '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01'};
final remoteCtx = W3CTraceContextPropagator.extract(Context.root, incoming);
ZoneContextStorage.runWithContext(remoteCtx, () {
final serverSpan = tracer.startSpan('GET /api/users', kind: SpanKind.server);
serverSpan.end();
});
recordException
try {
throw FormatException('Invalid JSON at line 3');
} catch (e, st) {
span.recordException(e, stackTrace: st,
attributes: Attributes.of({
'code.file': AttributeValue.string('parser.dart'),
'code.line': AttributeValue.int(42),
}),
);
}
// Produces SpanEvent: exception.type, exception.message, exception.stacktrace
Samplers
// Everything
SDKTracerProvider(sampler: const AlwaysOnSampler(), ...);
// 10% of root spans, respect parent decision
SDKTracerProvider(sampler: ParentBasedSampler(TraceIdRatioBasedSampler(0.1)), ...);
// Benchmark mode — zero overhead
SDKTracerProvider(sampler: const AlwaysOffSampler(), ...);
SpanLimits
SDKTracerProvider(
spanLimits: const SpanLimits(maxAttributes: 64, maxEvents: 256, maxLinks: 32),
...
);
Logs Pipeline
final logProvider = SDKLoggerProvider(
resource: Resource.empty,
processors: [SimpleLogRecordProcessor(const ConsoleLogRecordExporter())],
);
final logger = logProvider.get('my-logger');
logger.emit(LogRecord(
timestamp: DateTime.now(),
observedTimestamp: DateTime.now(),
severityNumber: Severity.warn,
body: AttributeValue.string('Cache miss'),
attributes: Attributes.of({'cache.key': AttributeValue.string('user:42')}),
));
Severity Levels (24 OTel levels)
| Band | Values |
|---|---|
| TRACE | Severity.trace — Severity.trace4 (1–4) |
| DEBUG | Severity.debug — Severity.debug4 (5–8) |
| INFO | Severity.info — Severity.info4 (9–12) |
| WARN | Severity.warn — Severity.warn4 (13–16) |
| ERROR | Severity.error — Severity.error4 (17–20) |
| FATAL | Severity.fatal — Severity.fatal4 (21–24) |
Trace Correlation
final span = tracer.startSpan('checkout');
final spanCtx = Context.root.withValue(spanContextKey, span);
ZoneContextStorage.runWithContext(spanCtx, () {
logger.emit(LogRecord(/* ... */));
// LogRecord.traceId == span.spanContext.traceId ← auto-inherited
// LogRecord.spanId == span.spanContext.spanId ← auto-inherited
});
span.end();
Metrics
Counter
final meter = meterProvider.get('order-service');
final counter = meter.createCounter('http.requests', unit: '1');
counter.add(1);
counter.add(1, attributes: Attributes.of({'status': AttributeValue.int(200)}));
counter.add(1, attributes: Attributes.of({'status': AttributeValue.int(404)}));
// 3 distinct time series, 3 active streams
Histogram (Int64List buckets)
final hist = meter.createDoubleHistogram('http.server.duration', unit: 'ms',
explicitBucketBoundaries: [5, 10, 25, 50, 100, 250, 500, 1000]);
hist.record(12.0, attributes: Attributes.of({'method': AttributeValue.string('GET')}));
hist.record(45.0, attributes: Attributes.of({'method': AttributeValue.string('GET')}));
// Per stream: {count, sum, min, max, bucketCounts}
UpDownCounter
final connections = meter.createUpDownCounter('db.connections');
connections.add(1);
connections.add(-1); // Accepts negative values — non-monotonic
Observable Instruments
final cpuGauge = meter.createDoubleObservableGauge('system.cpu.usage',
callback: () => [Measurement(0.42, attributes: Attributes.of({'core': AttributeValue.int(0)}))],
);
PeriodicExportingMetricReader
final reader = PeriodicExportingMetricReader(
exporter: OtlpHttpMetricExporter(endpoint: Uri.parse('https://collector:4318')),
interval: Duration(seconds: 60),
temporality: Temporality.delta,
);
Cardinality Limits
final counter = LongCounter(2000); // max 2000 attribute combinations
// After 2000 streams, overflow handled gracefully
counter.store.activeStreams; // current count
counter.store.overflowCount; // dropped due to overflow
OTLP Export — The Differentiator
Manual Protobuf Encoder
PurpleOTel ships a ~500-line hand-written protobuf encoder producing valid OTLP wire format — zero generated .pb.dart files, zero code generation step.
| Approach | Bundle cost | Codegen required | Tree-shakable |
|---|---|---|---|
| Generated (dartastic) | ~3MB (75+ files) | Yes — protoc plugin |
No |
| Manual (PurpleOTel) | ~5KB (~500 lines) | Zero | Yes — per signal |
// OtlpTraceEncoder.encode() produces binary protobuf identical to generated code
final body = OtlpTraceEncoder.encode(spans, resource: resource, scope: scope);
// body is Uint8List — ready for HTTP POST with Content-Type: application/x-protobuf
Collector Integration
All backends supporting OTLP work transparently:
// OpenTelemetry Collector
OtlpHttpSpanExporter(endpoint: Uri.parse('http://localhost:4318'));
// Jaeger (via OTLP, v1.35+)
OtlpHttpSpanExporter(endpoint: Uri.parse('http://jaeger-collector:4318'));
// Grafana Tempo
OtlpHttpSpanExporter(endpoint: Uri.parse('https://tempo.example.com:4318'));
// Grafana Loki (v3.0+)
OtlpHttpLogRecordExporter(endpoint: Uri.parse('https://loki.example.com:3100/otlp'));
// Datadog
OtlpHttpSpanExporter(endpoint: Uri.parse('https://otlp-gateway.datadoghq.com'),
headers: {'DD-API-KEY': 'your-key'});
// Azure Monitor (via Collector)
OtlpHttpSpanExporter(endpoint: Uri.parse('https://my-collector.azurewebsites.net:4318'));
Auto-Instrumentation
This is the key differentiator. PurpleOTel does auto-instrumentation. dartastic doesn't.
purple_otel_http — Now part of the SDK
final client = OtelHttpClient(inner: http.Client(), tracer: tracer);
// Every request auto-creates a CLIENT span, injects traceparent, maps status
final response = await client.get(Uri.parse('https://api.example.com/users'));
// 34 lines of manual instrumentation → 1 line
purple_otel_dio — 1 Line
final dio = Dio()..addOtelInterceptor(tracer);
final users = await dio.get('/users');
// Every Dio request auto-traced
purple_otel_flutter — 3 Lines
// Navigation
MaterialApp(navigatorObservers: [OtelNavigatorObserver(tracer: tracer)]);
// Error capture
WidgetsBinding.instance.initializeOtel(tracerProvider: tracerProvider);
// Lifecycle
WidgetsBinding.instance.addObserver(OtelWidgetsBindingObserver(tracer: tracer));
purple_logger → OTel Bridge
final factory = LoggingBuilder()
.addProvider(PurpleOtelLoggerProvider(otelProvider: sdkLogProvider))
.build();
factory.createLogger('api').info('Order placed');
// LogRecord with severity mapping + trace correlation → OTLP collector
Before vs After
Without PurpleOTel: ~890 lines of manual observability boilerplate (span creation, header injection, status mapping, error recording, log correlation).
With PurpleOTel: ~20 lines of declarative setup. Everything else automatic.
Performance
Bundle Size
| Metric | dartastic (generated) | PurpleOTel (manual) |
|---|---|---|
| Source files | 75+ .pb.dart |
4 encoder files |
| Source code | ~3MB | ~15KB (~500 lines) |
| Compiled AOT | ~500KB–1MB | ~5KB |
| Protobuf runtime | package:protobuf + fixnum (~550KB) |
Zero |
Memory Budget
| Object | Size |
|---|---|
| Span (zero attrs) | ~300 bytes |
| Span (+10 attrs, 2 events) | ~900 bytes |
| LogRecord (body only) | ~250 bytes |
| HistogramAggregator (15 buckets) | ~200 + 120 bytes |
Key Optimizations
- Lazy attribute map allocation — no map until first
setAttribute() - String interning — attribute keys shared across all spans
- Int64List histogram buckets — zero per-bucket object overhead
- Instrument caching — same tracer/meter/logger reused across
get()calls - Shared OTLP HTTP client — one connection pool for all 3 signal exporters
- No delegation pattern — direct implementations, no wrapper classes
- Tree-shakable encoders — unused signal encoders eliminated by Dart's tree shaker
Architecture
packages/shared/
├── purple_otel_api/ # API: interfaces, types, no-ops (zero deps)
├── purple_otel_sdk/ # SDK: traces, logs, metrics, OTLP, W3C
├── purple_otel_http/ # Auto-instrumentation: package:http
├── purple_otel_dio/ # Auto-instrumentation: Dio
├── purple_otel_flutter/ # Auto-instrumentation: Flutter
├── purple_logger_otel/ # Bridge abstractions (zero SDK deps)
└── purple_logger_otel_sdk/ # Bridge: purple_logger → PurpleOTel
Data Flow
tracer.startSpan() → SDKSpan (lazy attrs) → span.end()
→ BatchSpanProcessor (queue + periodic flush)
→ OtlpHttpSpanExporter.export()
→ OtlpTraceEncoder.encode() [manual protobuf]
→ OtlpHttpClient.send() [HTTP POST /v1/traces with retry]
→ OpenTelemetry Collector → Jaeger/Tempo/Grafana
Quality & Reliability
PurpleOTel is built to run in production — not just pass a quick demo. Every component is hardened against edge cases discovered through adversarial testing.
By the Numbers
| Metric | Value |
|---|---|
| Total tests | 256 (95 SDK + 107 logger + 54 Flutter) |
| Test failures | 0 |
| Red team audit | Passed — 8 critical bugs found and fixed |
| NaN/Infinity safe | ✅ Rejected at aggregation layer |
| Config validation | ✅ Batch processors guard against zero/invalid values |
| Span immutability | ✅ All mutations blocked after end() |
| Error truncation | ✅ Messages limited to 256 characters |
| Cyclic data safe | ✅ hashCode and equality protected against cyclic maps |
| Disposed dependencies | ✅ All callbacks try-catch wrapped |
Production Hardening
- Span end() immutability: After a span is ended,
setStatus(),recordException(),updateName()are no-ops. No accidental mutations in production. - NaN/Infinity rejection:
DoubleHistogramAggregatorsilently dropsNaN,+Infinity,-Infinity. Your dashboards stay accurate. - Config validation:
BatchConfig.validated()ensuresmaxQueueSizeandmaxExportBatchSizeare always ≥ 1. Zero crashes from misconfiguration. - Safe error handling: If an exception's
toString()method itself throws, the SDK catches it and records<error>instead of crashing. - Cyclic map protection: If structured log properties contain self-referencing maps,
hashCodecomputation uses try-catch fallback instead ofStackOverflowError. - Double-initialization guard:
FlutterOtelInitializercan be called multiple times safely — only the first call takes effect. - Disposed tracer safety: All observer callbacks are wrapped in try-catch. A disposed tracer never crashes the Flutter framework.
- String interning: Attribute keys are interned to minimize GC pressure in high-throughput scenarios.
- Lazy allocation: Span attribute maps, event lists, and link lists are only allocated when first used. Zero-cost spans in the common case.
Production Deployment
Retry with Backoff
// Built into OtlpHttpClient:
// - Up to 3 attempts per export
// - 200ms × attempt delay
// - Retries on: 5xx, 429, network errors
// - No retry on: 4xx client errors
File Logging (via purple_logger)
final factory = LoggingBuilder()
.addFile('/var/log/app.log',
rotation: const RotatingFileConfig(maxFileSizeBytes: 50 * 1024 * 1024, maxFiles: 10, compressRotated: true))
.addProvider(PurpleOtelLoggerProvider(otelProvider: sdkLogProvider))
.build();
Environment Configuration
export PLOG_LEVEL=info
export PLOG_FORMAT=json
export PLOG_OUTPUT=both
export PLOG_FILE_PATH=/var/log/app.log
Companion Packages
| Package | Description |
|---|---|
| purple_otel_api | API interfaces — zero dependencies |
| purple_otel_dio | Auto-instrumentation for Dio |
| purple_otel_flutter | Auto-instrumentation for Flutter |
| purple_logger_otel_sdk | Bridge: purple_logger → PurpleOTel |
| purple_logger | Enterprise structured logger |
Built by PurpleSoft
PurpleSoft S.r.l. — software house with offices in Monza, Milano, and Lugano (Switzerland). Since 2017.
We build what doesn't exist yet.
The sectors we dominate
Database & Storage Engines. We design, implement, and ship production-grade databases. Our LSM-tree storage engine — built in Rust for performance, exposed via FFI to Dart/Flutter, compiled to WASM for web — uses WiscKey-style key-value separation with concurrent compaction and crash recovery. It runs on all 6 platforms. This is not a wrapper around SQLite. It is a ground-up embedded NoSQL database written from scratch.
Conversational AI & Voice Assistants. We ship end-to-end AI voice platforms — from the physical device (ESP32 with custom Opus codec firmware) to the cloud backend (.NET 10, ASP.NET Core, Blazor Server) to the mobile companion app (Flutter with BLE). Our multi-agent LLM architecture orchestrates specialized agents (conversation, memory, content enrichment, research) with a multi-layered memory system spanning graph, episodic, and working memory — including adaptive forgetting, poison detection via statistical outlier analysis, and automatic episodic-to-semantic compression. Our content intelligence pipelines ingest, enrich via LLM, embed (1024-dim vectors), and serve via hybrid semantic search with HNSW indexing and training-free chunk pre-filtering — 28,000+ items enriched, 30,000+ embeddings generated.
Fintech & Payments. We build payment orchestration layers that abstract multiple gateways and cryptocurrencies behind a single API. Our engineers have shipped POS terminal firmware, fiscal receipt systems compliant with Italian regulatory standards, and cash register management platforms processing millions of transactions.
Cybersecurity & Identity. We ship post-quantum cryptography implementations using NIST-standardized algorithms on .NET 10 — the cryptography standard that will replace RSA and ECC. Our authentication infrastructure integrates SPID (Italian public digital identity) and OAuth 2.0/OpenID Connect across every major identity provider. We build digital signature platforms with PKCS#11 HSM support handling the full envelope lifecycle.
Artificial Intelligence & On-Device ML. We deploy ONNX models to phones via custom Dart runtime bindings with GPU acceleration. We integrate on-device LLM inference engines. We build neural text-to-speech engines that run across all 6 Flutter platforms and speech recognition systems with Italian dialect support. Our scientific research pipeline uses a multi-scorer verification chain with consensus voting to ensure factual accuracy in LLM outputs.
Enterprise SaaS & Cloud-Native Architecture. We architect and operate platforms at enterprise scale. Our .NET 10 monorepo spans 239 C# projects with consistent CI/CD, 297 test suites, zero build errors. We design multi-engine database abstraction layers (PostgreSQL, MySQL, Microsoft SQL Server) with automated schema-to-code generation. Our notification engine handles 6 channels with DNS-based email validation.
IoT & Embedded Systems. We write ESP32 firmware targeting ESP-IDF v5.4 with 21 FreeRTOS tasks, I²S audio pipelines, Opus codec integration, and a validated WiFi state machine. We build certificate authority infrastructure for device TLS. We write native Flutter plugins for hardware that doesn't have one yet.
Observability & DevOps. Full-stack observability is the foundation of our client solutions. We ship a complete OpenTelemetry SDK for Dart/Flutter (traces, logs, metrics, W3C propagation, OTLP export), enterprise structured logging with file rotation, and auto-instrumentation — all red-team audited with 256 automated tests.
The technologies we master
Our engineering team works across the full stack — from ESP32 firmware to cloud-native backends to cross-platform mobile apps. We write production code in Rust (database engines, FFI), C# (.NET 10), Dart/Flutter, C (ESP-IDF, audio codecs), TypeScript, Python, and C++. Our frameworks of choice are ASP.NET Core, Blazor Server, Flutter, Angular, and React. We operate Microsoft Azure (Key Vault, Blob Storage, IoT Hub), deploy on NGINX, and manage PostgreSQL + pgvector, MySQL, Microsoft SQL Server, and ESP-IDF at scale. Our database layer uses EF Core with Npgsql, our embedded engine uses Rust + dart:ffi + WASM. Our AI stack spans LLMs, embeddings, vector search, semantic kernel, and multi-agent orchestration. We use FFmpeg for audio, LVGL for embedded displays, and BLE for device provisioning. Our CI/CD runs on Azure Pipelines.
Microsoft Partner since 2018 · SumUp Partner · Dell Partner
Trusted by
ABB Intesa Sanpaolo Tenaris Reply Aubay Comune di Milano BCC FIMAP Alten Altran Prometeia illimity Be Shaping the Future DS Group NVALUE Inoptim Docflow P&C
and 40+ other enterprises across banking, manufacturing, energy, and public sector.
Your project can't wait. We've solved these exact problems for companies you know. Let's solve them for you.
🌐 purplesoft.io · 📧 developers@purplesoft.io · 📞 +39 0362 148 3978 · 💼 LinkedIn · 🐙 GitHub
License
AGPL-3.0 — see LICENSE.