Changelog
All notable changes to TRAM are documented here. Format follows Keep a Changelog.
Unreleased
Added
- Added local-image retention cleanup to both
scripts/deploy-docker-standalone.shandscripts/deploy-kind-tram-dev.sh, keeping the newest 5local-*images per repository by default with a--keep-imagesoverride - Added
scripts/deploy-docker-standalone.shto build, run, and manage a single standalone TRAM Docker container with a persisted Docker data volume, auto-created host pipeline mounts, optional host output bind mounts, timestamp-tagged local auto-builds for repo workflows,--ghcrpull support for the publishedghcr.io/tosumitdhaka/trishul-ram:<tag>image, log/status helpers, optional UDP port publishing, and a README-friendly GitHub bootstrap flow
1.3.3 — 2026-05-01
Added
Overview and cluster operator visibility
- Dashboard overview now exposes split 15-minute Records In/Out and Bytes In/Out cards, a bytes-processed load chart, chart metric toggle, bucket tooltip, and manual refresh affordance
- Cluster now surfaces runtime-first summary cards, merged input/output traffic cards, processed records/bytes totals per worker, and live records/bytes in/out metrics in both the summary row and worker details
Shared UI/runtime helpers
- Shared triggered-run monitoring now backs dashboard, pipelines, and detail lifecycle actions
- Shared YAML diff rendering now backs both detail and editor version/result flows instead of separate page-local implementations
Changed
Shell, routing, and templates
- Hash navigation now preserves browser-history semantics;
#templatescanonicalizes through Pipelines with the templates modal active, and#wizardreturns to Pipelines while the wizard stays intentionally disabled - The old dedicated Templates page was removed; template browsing is consolidated into the Pipelines modal and aligned to the shared modal/button system
- Login/bootstrap flow no longer flashes protected-page unauthorized errors on fresh-session deep links
Pipeline detail, editor, and settings surfaces
- Pipeline detail now opens on
Runsby default, uses a streamlined header/tool layout, and presents version history through a shared scrollable compare/view/copy/download/rollback workflow - Version history semantics were tightened so duplicate identical YAML versions are suppressed and rollback re-activates the existing historical version instead of cloning it
- Editor, diff, dry-run, connector-test, and AI result surfaces now use theme-safe shared cards/panels instead of page-local dark-only treatments
- Settings remains configuration-focused while live runtime daemon state moved into Cluster
Fixed
Runtime truth and action feedback
- Stopped manual pipelines now consistently surface
Run nowinstead ofStartacross overview and detail flows - Manual/batch trigger attempts with no healthy workers now record an actionable failed run with
No healthy workers available for dispatchinstead of only flipping pipeline state - Pipeline action feedback now reflects backend truth (
started,already active,manual,disabled in YAML) rather than optimistic success toasts - Detail version-YAML caching is now scoped per pipeline, preventing cross-pipeline bleed between version viewers and compare flows
Supporting pages and assets
- Schemas and MIBs pages gained search and cleaner shared styling; MIB management now persists raw ASN.1 source files alongside compiled artifacts for later dependency-resolving compiles
- Runs export/filter behavior is aligned to the backend contract, and noisy repeated skip-note rendering was reduced in the shared runs table
- Cluster worker assignment display now follows current controller truth instead of stale worker-pool history
Validated
- Full manual validation completed across the active UI surface in both manager and standalone modes
- Local release validation completed for
1.3.3:ruff check ., fullpytest tests/ -q, andcd tram/ui && npm run build
1.3.2 — 2026-04-21
Added
Standalone live stats parity
- Local stream runs on standalone deployments now create a
PipelineStatsentry inStatsStore;GET /api/pipelines/{name}/placementreturns a synthetic single-slot view instead of 404 - A background stats loop emits live
uptime_secondsupdates; a lock-guarded race fix prevents a stopped stream from being re-inserted after exit - Stats loop is suppressed in manager mode (worker stats flow through the normal stats callback path)
Manager operational metrics
- 8 new
tram_mgr_*Prometheus series:tram_mgr_dispatch_total,tram_mgr_redispatch_total,tram_mgr_reconcile_action_total,tram_mgr_placement_status,tram_mgr_worker_healthy,tram_mgr_worker_total,tram_mgr_run_complete_received_total,tram_mgr_pipeline_stats_received_total - All series have
_NoOpfallbacks whenprometheus_clientis not installed;/metricsreturns 503 with install hint instead of 500 /metricsdocstring clarifies that series are process-local to the manager; worker execution metrics require scraping each worker pod
UDP multi-worker streams
syslogandsnmp_trapsources now supportkubernetes: enabled: truein manager mode;KubernetesServiceManager.is_eligibleextended to all push sources- UDP Services use
protocol: UDP; service/target port derived fromsource.port(fallback: 514 for syslog, 162 for snmp_trap); overridable viakubernetes.port/kubernetes.target_port count: Nandworkers.listuse manualEndpointsobjects targeting only dispatched worker pods;count: alluses the broad worker label selectorKubernetesServiceConfiggains optionalport,target_port,load_balancer_ip,annotationsfields;ClusterIPadded as validservice_typedelete_service()now cleans up manualEndpointsfor bothworkers.listandcount: Npipelines
Large-batch resilience
BatchReconcilernow runs alongsidePlacementReconcilerso the manager can scan worker/agent/status, adopt orphaned running batch runs after restart, and synthesize failures when a worker-owned batch run disappears before callback- batch run completion paths now reuse the normal controller finalization logic, so manual, interval, and cron pipelines keep the same status transitions even when a lost run is reconciled instead of completing normally
record_chunk_sizewas added to pipeline config for bounded record windows in serial batch runs; serializers can implementparse_chunks(data, record_chunk_size)and the ASN.1 serializer now decodes concatenated BER payloads incrementally instead of materializing one giant list first
Changed
Linter rules
- L008 removed (was blocking all UDP push sources in manager mode)
- L012 added (error): UDP push sources in manager mode require
kubernetes: enabled: true— no pre-existing shared UDP ingress exists in the worker chart - L006 updated:
kubernetes: enabled: truenow permitscount: Nandworkers.listin addition tocount: all; the controller threadsdispatched_worker_idsinto the service manager to avoid over-selection
Safe staged file output cleanup
- local and SFTP sinks now delete their run-scoped staged temp file on
finalize_source(..., success=False)instead of leaving it behind - when a new staged write begins for the same deterministic final filename, stale
.tram-*.tmpartifacts from failed prior attempts are discarded before the new run writes - staged safe-finalize is applied only to record-safe serializers (
csv,ndjson) in the serial batch path and publishes output only after a source file completes successfully
Optional post-batch heap cleanup
- batch-mode heap cleanup is now pipeline-controlled via
post_batch_cleanup: trueinstead of being enabled globally for every batch run - when enabled, the executor performs
gc.collect()and best-effortmalloc_trim(0)after the batch result is finalized; default remains off to avoid surprising process-wide pauses on shared workers
Validated
- kind cluster:
snmp_trap → filewithcount: all— NodePort UDP Service created; ECMP routes each sender consistently to one worker - kind cluster:
snmp_trap → filewithcount: 2— manual Endpoints target exactly the 2 dispatched worker pods - kind cluster:
webhookwithcount: 2+kubernetes: enabled: true— no L006; HTTP Endpoints target exactly 2 workers (HTTP regression confirmed) - kind cluster:
snmp_trapwithout kubernetes block — L012 fires; add block — L012 clears, no L008
ASN.1 structured decode flattening
split_records: trueon theasn1serializer splits concatenated BER files into individual top-level TLV records before decode; supports short-form, long-form, and indefinite-length (0x80) encodingsmessage_classes: [...]accepts an ordered fallback list of root ASN.1 types; all-fail raisesSerializerErrorand routes to DLQ; mutually exclusive with the existingmessage_classfieldbytearrayvalues are now hex-stringified in_to_json_safe()alongsidebytesjson_flattentransform — now uses the explicit ordered row-shaping contract for nested payloads withexplode_paths,zip_groups,choice_unwrap, final dotted-key flattening, anddrop_pathson flattened keys; this replaces the earlier heuristicexplode_mode/zip_listsbehaviorhex_decodetransform — registered as"hex_decode"; decodes hex-string leaf values produced by_to_json_safe();mode: utf8_or_hex|latin1_or_hex|hex; per-pathoverrideswithdecode_as,format, optionalbit_length_field, optional bit-indexmapping, andoutputforbit_flags(names|indexes|both); does not re-invoke asn1tools
CDR record shaping — dotted-path transform support
- Shared
tram/transforms/path_utils.py—get_path,set_path,delete_path,rename_pathhelpers with consistent dict-only traversal semantics; used by all path-aware transforms unnest,explode,drop,rename,value_map,cast— allfield/fieldsconfig keys now accept dotted paths (a.b.c); plain top-level keys unchanged; list-index syntax not supportedprojecttransform — declarative final-schema extraction/rename step with compactoutput: source.pathform plus expandedsource,source_any,default, andrequiredoptionsunnest: missing nested path passes through unchanged; only present non-dict values triggeron_non_dictbehaviorexplode: scalar elements in nested lists write back viaset_pathto the correct nested locationrename: prefix-overlap detection at init time raisesTransformErrorfor conflicting source paths; both source and destination may be dotted- All path-mutating transforms use
deepcopyper record to prevent nested mutation leaking across output rows
CDR record shaping — new primitives
select_from_listtransform — selects elements from a list field by exact-match predicate orfirst_item: truewithout exploding the record; multi-select in one invocation viaselect: [...]; projects element fields to top-level output names;on_no_match: null_fields|raise(defaultnull_fields);nameoptional for error context; duplicate output fields across selections rejected at config loadcoalesce_fieldstransform — writes each output field from the first non-empty candidate path insources; defaultempty_valuesis[null, ""];defaultused when all candidates missdroptransform —fieldsnow accepts eitherlist[str]for unconditional drops ordict[path, list[value]]for conditional drops; conditional matching supports dotted paths and removes a field only when its value equals one of the configured values- light path-pattern support —
hex_decode.overrides[].pathandjson_flatten.drop_pathsnow accept simple single-segment*wildcards; exact path matches keep precedence over wildcard rules - Both transforms registered in
tram/transforms/__init__.pyand included in theTransformConfigunion inpipeline.py
1.3.1 — 2026-04-20
Added
Placement and K8s exposure for push streams
workers.count: Nandworkers.listplacement behavior is now implemented for multi-worker push streams in manager mode- Dedicated per-pipeline Kubernetes Service provisioning is now available for active
webhookandprometheus_rwstream pipelines workers.listdedicated Services use explicitEndpointstargeting only the selected worker pods
File sink naming and partitioning
- File sinks now support shared filename variables derived from source context, including
source_stem,source_suffix, andsource_path - Executor-side record partitioning is now available for file sinks via dotted
{field.*}filename variables - Rolling file output now supports
max_records,max_time, andmax_bytesin append mode across local, SFTP, FTP, S3, GCS, and Azure Blob sinks
Changed
SNMP dependencies
- SNMP connectors now target
pysnmp>=7,<8with compatibility helpers for the 7.x HLAPI surface - Legacy lextudio-specific runtime package references were removed from the implementation path
Helm / K8s defaults
- Manager resource settings are now resolved from
manager.resourcesbefore falling back to top-level defaults - Kind/dev chart values continue to live in
helm/values.yaml; generic release-oriented defaults live inhelm/values-template.yaml
Fixed
workers.listdedicated pipeline Services now repatch manualEndpointswhen placement changes, including worker disappearance during scale-down- Dedicated
workers.listservices no longer retain stale pod IPs after a pinned worker becomes unavailable - Alert cooldown logic, placement reconciliation, and ingress split behavior were revalidated against the
1.3.1release build on a live kind cluster
Tests
- Full local release validation completed for
1.3.1: lint, unit, integration, and coverage - Helm validation completed, including dependency update and chart lint
- Live kind validation completed for manager/worker rollout, push ingress, placement APIs, dedicated Services, and scale-down stale-slot recovery
1.3.0 — 2026-04-17
Added
Multi-worker streams for HTTP push sources
workers:config now supports multi-worker placement forwebhookandprometheus_rwin manager mode, defaulting those sources tocount: allWorkerPool.multi_dispatch()and placement tracking allow a single stream pipeline to run across all healthy workers- New placement visibility endpoints:
GET /api/pipelines/{name}/placementGET /api/cluster/streams
Placement persistence and reconciliation
- Active multi-worker placements are persisted in
broadcast_placements PlacementReconcilerdetects stale slots, re-dispatches recovered workers, and restores placement state after manager restart- Placement slot metadata now persists immutable
run_id_prefixand mutablecurrent_run_id
Unified pipeline stats and load-aware dispatch
- Batch and stream runs now share
PipelineStatswith records, bytes, error counters, and rolling error windows - Workers report periodic stats to the manager; final batch totals are persisted through the run-complete path
StatsStoreis now keyed byrun_idwith stale-aware lookups for reconciliation and placement views
Changed
Worker ingress split
- Worker pods now run two listeners:
- internal agent API on
:8766 - ingress-only webhook receiver on
:8767
- internal agent API on
- Worker
/agent/healthnow reports composite status and fails when the ingress listener is down
Manager Helm deployment
- Manager changed from
Deploymentto single-replicaStatefulSet - Added manager headless service and
manager.persistence.existingClaimsupport for Deployment → StatefulSet upgrades - Worker StatefulSet now exposes ingress port
8767
Alerts
- Alert cooldown is now armed only after confirmed webhook or email delivery succeeds
Fixed
- Multi-worker stream slot completion no longer drives the pipeline state machine while sibling slots are still running
- Intermediate placement slot completion no longer evicts stats too early and trigger re-dispatch storms
- Stream run completion now persists final counters instead of zero totals in run history
Tests
- Expanded unit coverage for worker ingress split, placement reconciliation, stats store, worker dispatch, pipeline controller, and placement/streams APIs
- Release-prep validation completed on 2026-04-17 for lint, unit/integration/coverage, Helm, and local kind deployment
1.2.3 — 2026-04-16
Fixed
SNMP poll — walk could stall indefinitely at subtree end
tram/connectors/snmp/source.py:_do_walk()now stops when returned OIDs fail to advance numerically, preventing an infinite loop when some agents repeat the terminal OID at the subtree boundary- Regression test added to
tests/unit/test_snmp_connectors.py
Manager + Worker — callback/run metadata correctness
- Real worker callback timestamps now propagate to manager run history instead of being overwritten locally
- Run IDs in
PipelineControllernow remain full UUIDs rather than truncated 8-character values
Browser auth bootstrap
- DB-backed browser auth no longer requires
TRAM_AUTH_USERSonce users exist in the database; docs and.env.exampleupdated to match implementation
SNMP trap sink config naming
trap_oidis now the documented/configured field for outgoing SNMP trap OID selection- Legacy
enterprise_oidremains accepted as a backward-compatible alias
Changed
ASN.1 serializer
- Documentation and tests now explicitly describe ASN.1 support as decode-only
- Added coverage for decode behavior, malformed input handling, and schema compile/cache paths
Example pipelines and docs
- Bundled pipeline examples were brought back in line with the current schema and are validated by test
- Quick-start/live docs continue to use
latest, while version-pinned examples were updated to1.2.3
SNMP validation
- Added SNMPv3 validation pipelines for real-device
GETandWALK - Verified SNMPv3
GETandWALKagainst a live host during release preparation
Tests
ruff check .passespytest tests/unit/test_loader.py -q -o log_cli=falsepassespytest tests/unit/test_snmp_connectors.py -q -o log_cli=falsepasses- Live SNMPv3 validation completed:
snmp_get_v3_system_to_sftp_json— successsnmp_walk_v3_iftable_to_sftp_json— success
1.2.2 — 2026-04-15
Fixed
CLI — validate and run --dry-run crashing on valid pipelines
load_pipeline()returns a(config, raw_yaml)tuple; both commands were treating it as a plain object, causingAttributeError: 'tuple' object has no attribute 'name'- Unpacking corrected in
tram/cli/main.py
Watcher — hot-reload raising PipelineAlreadyExistsError
pipeline_watcher.pynow passesreplace=Trueandyaml_texttomanager.register()on file change; previously a changed YAML caused an error instead of updating the pipeline
Docs — API response shapes drifted from implementation
docs/api.md: corrected response shape for dry-run ({valid, issues[]}), connector test ({ok, latency_ms, error}), and change-password ({ok, username})docs/connectors.md:on_errorvalid values fixed tocontinue | abort | retry | dlq;stopwas documented but never accepted by the model
CI — omniORBpy pip install failure
omniORBpyis a system-only package (requires omniORB shared libs); it cannot be installed from PyPI as a wheel- Removed
tram[corba]from theallpip extra; thecorbaextra itself remains for users who have omniORB installed on their system
Tests — stale sha256$ assertion in auth tests
test_auth_utils.py:test_returns_sha256_prefixupdated totest_returns_scrypt_prefix— the password hasher was upgraded to scrypt in v1.2.1 but the test was not updated
Changed
Repository layout
tram-ui/moved totram/ui/for a cleaner project structure;Dockerfilepaths updated accordinglyCHANGELOG.mdandCHECKLIST.mdmoved intodocs/aschangelog.mdandchecklist.md; broken../CHANGELOG.mdlink indocs/index.mdfixed
Documentation
README.mdoverhauled: rewritten around concrete telecom use cases (PM collection, SNMP trap mediation, gNMI telemetry, syslog aggregation, CORBA mediation) with YAML examples; version history section replaced with link to changelogdocs/roadmap.mdcreated: replacesdocs/roadmap_1.2.0.md; features/issues only, versioned where confirmed, unassigned items in backlogdocs/index.md: version updated to 1.2.2; roadmap and checklist linked.gitignore:CLAUDE.md,AGENTS.md,.codexadded (AI assistant context files, local only)
Tests
- Unit coverage raised from ~67% to 78.5% (1,296 passing tests; threshold: 60%)
- 9 new test files:
test_api_ai.py,test_api_health_runs.py,test_api_middleware.py,test_api_stats_db.py,test_bytes_serializer.py,test_cli_main.py,test_daemon_server.py,test_pipeline_manager.py,test_pipeline_watcher.py - Extended:
test_pipeline_controller.py,test_loader.py,test_protobuf_serializer.py,test_snmp_connectors.py - 25 ruff lint errors in test files resolved (unused imports, unsorted blocks, unused variables)
1.2.1 — 2026-04-14
Fixed
Manager + Worker — run metrics propagation
records_skippednow correctly propagates from worker executor through the HTTP callback to the manager DB and UI run history (was always 0 in worker mode)- Per-record
errorslist now flows through the full worker callback chain (executor → _post_run_complete → RunCompletePayload → on_worker_run_complete → DB errors_json) — skip reasons and transform/sink errors are now visible in the run detail expandable row RunCompletePayloadextended witherrors: list[str]field;on_worker_run_completeaccepts and stores it
Executor — skip reason visibility
- Skip path (no sink wrote — condition filtered or all sinks failed/circuit-open) now logs at WARNING instead of DEBUG, and appends the reason to
ctx.errorsvia newPipelineRunContext.note_skip()method note_skip()appends to errors without incrementingrecords_skipped(avoids double-counting)
Manager logs — health poll noise
httpxlogger set to WARNING inlog_config.py— individual per-request lines no longer flood the manager logWorkerPool._poll_all()now emits a singleWorker pool: N/M healthysummary line only when the healthy count changes; logs at WARNING when degraded, INFO when fully healthy
Settings page — Daemon Status
/api/readynow returns aclusterfield:"manager · N/M workers"in manager mode,"standalone"otherwise- Settings Daemon Status row previously showed
disabled (standalone)for all deployments — now correctly reflects the running mode
Changed
UI — Dashboard actions
- Replaced single Run Now / Stop toggle with separate Start, Stop, and Download YAML buttons per pipeline row
- Run Now removed from dashboard; one-shot trigger remains on the pipeline detail page only
UI — Pipeline detail
- Added Run Now button (lightning icon) as a separate one-shot trigger independent of the Start/Stop schedule buttons
- Run Now on a stopped pipeline no longer re-schedules it —
_on_run_completecorrectly restoresstoppedstatus when_may_schedule()returns False
UI — Workers page
- Renamed “Cluster” → “Workers” in navigation
- Per-worker card now shows
assigned_pipelines(most recent dispatch per pipeline) with currently-running ones highlighted in green WorkerPooltracks_pipeline_worker: dict[str, str]for dispatch history; exposesassigned_pipelinesinstatus()
UI — Light/dark mode
- Replaced all remaining hardcoded dark hex values (
#0d1117,#161b22,#30363d,#e6edf3,#8b949e) with CSS variables acrosseditor.html,wizard.html,cluster.html,plugins.html,settings.html,templates.html - Added
aria-labelattributes to unlabelled form controls inruns.html,wizard.html,detail.html(resolves browser accessibility warnings)
Load balancing
WorkerPool.least_loaded()uses round-robin tiebreaker (_rr_counter) among equally-loaded workers — prevents all pipelines being dispatched toworker-0when all workers are idle
Worker image
- Dedicated
Dockerfile.workervalidated in production; workers now deploy withtrishul-ram-workerimage (no apscheduler/sqlalchemy/UI assets)
1.2.0 — 2026-04-10
Added
Manager + Worker mode
-
New TRAM_MODEenv var:standalone(default)managerworker - Manager Deployment — owns all scheduling, DB writes, and UI; dispatches pipeline run requests to worker pods via HTTP and receives results via POST callback
- Worker StatefulSet — stateless executors: receive a run request, execute the pipeline, POST result back to manager; no DB access, no scheduler, no UI
- Worker discovery via Kubernetes headless DNS:
<release>-worker-N.<release>-worker.<ns>.svc.cluster.local - New env vars:
TRAM_WORKER_REPLICAS,TRAM_WORKER_SERVICE,TRAM_WORKER_NAMESPACE,TRAM_WORKER_PORT
tram[manager] optional extra
apscheduler>=3.10,sqlalchemy>=2.0,psycopg2-binary, andPyMySQLmoved from base dependencies intotram[manager]- Worker image installs only
tram[worker,kafka,snmp,avro,...]— no scheduler or DB libraries daemon/server.pychecksTRAM_MODE=workerbefore importing the manager module chain — worker boots cleanly withouttram[manager]installed
Dockerfile.worker
- Separate worker image: base deps + connector extras only (no
managerextra) - UI assets omitted — no
COPY tram-ui/dist /uistage EXPOSE 8766,ENV TRAM_MODE=worker, healthcheck on/agent/health
Helm: manager+worker mode (manager.enabled=true)
- Manager Deployment + Worker StatefulSet created; standalone StatefulSet skipped
manager.persistence— dedicated ReadWriteOnce PVC (manager-data-<release>) for SQLite DB + schemas + MIBs; RWO is sufficient since only one manager pod writesworker.imageoverride block — optionally point workers at a dedicated worker image; falls back to main image when unset (tram.workerImagehelper in_helpers.tpl)- Main Service adds
app.kubernetes.io/component: managerselector in manager mode — prevents HTTP traffic from reaching worker pods (port 8766) - Headless service
<release>-workerfor stable pod DNS
melt transform — wide → long pivot: converts a dict-valued field into one record per key/value pair; supports label_fields unnesting, include_only/exclude key filtering, and configurable output column names (metric_name_col, metric_value_col)
pm_xml serializer — 3GPP PM XML (Nokia NCOM / TS 32.432 measData) deserializer; produces one flat record per measValue; auto-closes truncated files; configurable managed_element and numeric casting
Changed
PipelineControllerreplaces splitTramScheduler+PipelineManagerlifecycle handling — single authority for all pipeline state transitions- 4-state machine —
pausedstate removed; states arescheduled,running,stopped,error _sync_from_db()stopped-flag detection — picks up DB stopped/cleared flags even when pipeline YAML is unchanged_seen_nodestracking — eliminates infinite cooling-period cycles when detecting newly joined cluster nodesTRAM_PIPELINE_SYNC_INTERVALdefault — reduced from 30 s to 10 s for faster convergence- UI — removed
paused/resumebuttons and badge; status filter updated (scheduled,running,stopped,error) - SQLite in manager mode — manager is the sole DB writer;
sqlite:////data/tram.dbon a RWO PVC is the recommended setup; no external database required postgresql.enableddefault — changed tofalse; SQLite onmanager.persistenceis the recommended default
Removed
- Deprecated DB columns —
owner_node,runtime_status,status_updated,status_nodeno longer added toregistered_pipelinesat startup - Dead cluster DB methods —
set_pipeline_owner,get_pipeline_owner,get_pipelines_by_owner,get_pipeline_counts_by_node,claim_orphaned_pipelines,set_runtime_status,get_pipeline_runtime,claim_run,get_all_pipeline_runtimeremoved fromTramDB
1.1.4 — 2026-04-08
Added
AI Assist — pipeline generation and modification in the YAML editor
- New AI panel in the YAML editor: “Generate” mode for new pipelines (describe in plain text → get YAML), “Modify” mode for existing pipelines (plain-English instruction → diff shown inline)
- Supports three providers:
anthropic(Claude),openai/ OpenAI-compatible (Ollama, LiteLLM, etc.),bedrock(AWS Bedrock proxy via Bearer token) - AI config (provider, API key, model, base URL) stored in DB
settingstable — survives pod restarts and overridesTRAM_AI_*env vars - New Settings page card: Save / Test AI config, shows enabled status and key hint
- New API endpoints:
GET /api/ai/config,POST /api/ai/config,POST /api/ai/test - AI context builder (
ai_docs.py) always includes CRITICAL RULES with full expression syntax reference — prevents AI from generating `` Jinja2-style expressions
YAML editor improvements
- Copy-to-clipboard button in editor toolbar
- “Diff vs saved” button (edit mode only): toggles an inline two-pane diff showing current edits vs last saved version; also auto-opens after AI modify
- Save button label changes to “Update Pipeline” in edit mode; no-op toast if YAML is unchanged
- Wider layout (8/4 column split) to accommodate AI panel alongside reference pills
Extended timestamp functions in add_field
now()→ UTC ISO-8601 string;now('%Y-%m-%d')/now('%H:%M:%S')etc. → strftime-formatted stringepoch()→ Unix timestamp float;epoch_ms()→ Unix milliseconds integer- Nested function calls work:
str(round(rx_mbps, 2)) + ' at ' + now('%H:%M:%S')
Pipeline context in add_field expressions
- Expressions now have access to a
pipelinevariable:pipeline.name,pipeline.source.host,pipeline.source.community, etc. - Both dot-access (
pipeline.source.host) and dict-access (pipeline['source']['host']) work - Injected at transform construction time via
_DotDictwrapper; available in global and per-sink transforms
DB as single source of truth for pipelines
- ConfigMap / filesystem pipelines seeded to DB at startup and on reload — not registered to manager directly
-
registered_pipelinesgains asourcecolumn (diskapi): disk seed skips pipelines withsource='api'(user-owned), preventing reload from reverting UI edits - Reload endpoint uses seed-then-
_load_from_db()— no more direct disk-to-manager registration or soft-deleted pipeline resurrection
Cluster pipeline update propagation
_sync_from_db()detectsyaml_textchanges from other nodes and re-registers the updated config (stop → deregister → re-register → reschedule)- Eliminates stale-config bug where node-1 kept running old pipeline YAML after node-0 saved an update
SNMP improvements
snmp_pollsource:classify: truemode adds_index_partslist metadata alongside_indexin classified output (multi-component OID index support)snmp_pollsource: real SNMP GET forsysDescr.0intest_connection()— verifies host, port, and community string with actual latency measurementsnmp_trapsource:test_connection()verifies UDP port bind availability
UI auto-refresh and refresh buttons
- Pipelines page auto-polls at configured interval (default 10 s)
- Refresh icon (
↻) added to: pipelines page toolbar, global run history page, pipeline detail run history tab - Pipeline page: separate Refresh (status only) and Reload (disk + DB sync) buttons
Helm: hostNetwork support
- New
hostNetwork: falsevalue (default off); settrueto share the kind/host network namespace - Required for UDP-based sources (SNMP, syslog) on WSL2 / kind where CNI overlay drops UDP return packets
- Sets
dnsPolicy: ClusterFirstWithHostNetautomatically when enabled
Fixed
- Pausing a disk-loaded (ConfigMap) pipeline now persists correctly across pod restarts — all pipelines are in DB from startup, so the
paused=1flag always has a row to update trigger_run()raises an error if the target pipeline is paused, preventing accidental manual execution- API key auth rate-limit middleware now exempts
/api/auth/loginand standard metadata endpoints (/docs,/redoc,/openapi.json,/favicon.ico) to avoid 429 on browser load
1.1.3 — 2026-04-01
Added
Test coverage — Tier 1 + Tier 2 unit tests
- 8 new unit test files covering API routers, auth utilities, and serializers:
test_auth_utils.py— token create/verify, password hash,parse_users,extract_bearertest_api_auth_router.py— login,/me, change-password (all happy + error paths)test_api_pipelines.py— pipeline CRUD, lifecycle (start/stop/run), dry-run, alerts CRUD, versions, reloadtest_api_connectors_router.py—/testand/test-pipelineendpoints, host/port extraction helperstest_api_stats_router.py— in-memory stats fallback, pipeline status counts, sparkline bucketstest_api_ai_router.py— AI status, generate/explain modes, error paths (503/502/400)test_api_misc_routers.py— webhooks (404/401/202/503), templates (cache, YAML parse), mibs (list/delete/upload)test_serializers_text_ndjson.py—TextSerializerandNdjsonSerializerparse + serialize + error paths
- 846 tests total (up from 701); coverage 69% (up from 63%); threshold 60%
1.1.2 — 2026-03-30
Added
ASN.1 serializer (type: asn1)
- Decodes BER/DER/PER/XER/JER binary files using a user-provided
.asnschema file — same pattern as theprotobufserializer (schema_file+message_class) - Encoding selectable via
encoding: ber | der | per | uper | xer | jer(default:ber) schema_filecan point to a single.asnfile or a directory of.asnfiles (compiled together for cross-file imports)_to_json_safe()convertsdatetime→ ISO 8601 string, ASN.1 CHOICE 2-tuples →{"type": x, "value": y},bytes→ hex- Schema compiled once per serializer instance and cached for its lifetime (same pattern as
protobuf) - Deserialize only (
serializer_in) — encode path raisesSerializerErrorwith a clear message pointing toserializer_out: type: json .asnadded toPOST /api/schemas/uploadaccepted extensions (displayed as typeasn1in the schemas list)- New optional extra:
tram[asn1]=asn1tools>=0.167; included in the standard Docker image - Reference schema
docs/schemas/3gpp_32401.asnfor Ericsson 3GPP TS 32.401 PM statsfiles (BER, IMPLICIT TAGS); uploadable via UI, works with C* (core) and G* (HLR/vHLR) variants
Fixed
Pipeline visibility across cluster nodes (API-registered pipelines)
POST /api/pipelineson any pod now writes the pipeline YAML to a sharedregistered_pipelinestable in PostgreSQL;PUTupdates it;DELETEsoft-deletes (setsdeleted=1)- On startup, after loading pipelines from
TRAM_PIPELINE_DIR(ConfigMap), the scheduler calls_load_from_db()— registers any DB pipeline not already loaded from the filesystem; filesystem wins on name collision - Background thread
_sync_from_db()polls the DB everyTRAM_PIPELINE_SYNC_INTERVALseconds (default 30): registers newly added pipelines, deregisters soft-deleted ones; all pods converge without restart - Pipeline registered via API on pod-0 becomes visible on pod-1 and pod-2 within one poll interval; status is consistent (hash-based ownership decides which pod executes it)
- New DB table
registered_pipelines(name, yaml_text, created_at, updated_at, deleted)— auto-created on startup via existing_create_tables()pattern; safe on existing databases - New config:
TRAM_PIPELINE_SYNC_INTERVAL(integer seconds, default 30) - SQLite (standalone, single pod): DB persistence still works, sync loop is effectively a no-op
Balanced pipeline distribution across cluster nodes
- Replaced simple
sha1(name) % node_countownership formula with rank-based assignment: all pipeline names sorted by stable hash then distributed round-robin (rank % count == position); guarantees at most 1 pipeline difference between any two nodes regardless of name hashes rebalance_ownership(all_names)pre-computes and caches the owned set as afrozenseton the coordinator; called on topology change, after startup load, and after each DB sync cycleget_state()(cluster API endpoint) uses the same rank-based formula so UI pipeline counts match actual ownership
Reload endpoint now restores DB-registered pipelines
POST /api/pipelines/reloadpreviously cleared all in-memory pipelines and re-scanned only the filesystem, causing API-registered pipelines to disappear until the next DB sync cycle (up to 30 s)- Fixed: after filesystem scan, reload now calls
_load_from_db()so all DB-registered pipelines are immediately available;totalin the response reflects the combined count
Cluster page — pipeline counts
- Node count and total pipeline count added to the cluster status line:
Cluster active · N nodes · M pipelines - Each node accordion header now shows a badge with its assigned pipeline count, right-aligned before the expand chevron
1.1.1 — 2026-03-30
Added
Run History — expandable error rows
- Runs with errors or DLQ records show a chevron (▶) in the detail page Runs table
- Clicking chevron inserts an inline sub-row with per-record error lines in monospace red; toggles closed on second click
- DLQ-only runs show “N record(s) sent to DLQ” when no inline errors; clean runs show no chevron
- Backend:
RunResult.errors: list[str]field populated fromPipelineRunContext.errors; persisted aserrors_json TEXTcolumn inrun_history(auto-migrated on existing DBs)
Wizard — complete connector coverage
wizard.jsFIELD_SCHEMA now covers all connector types includingwebsocket,gnmi,snmp_poll,prometheus_rw, andcorbasnmp_pollOID list rendered as a YAML sequence (addedoidstoARRAY_FIELDS)- All FIELD_SCHEMA and TRANSFORM_FIELDS entries have descriptive hint text
Wizard — step reorder to match YAML field order
- Steps: Info (name/schedule/on_error) → Source (type + serializer_in + Test) → Transforms → Sinks (global serializer_out + sink cards) → Review
serializer_inmoved to Source step;on_erroradded to Info step with inline descriptions; globalserializer_outadded to top of Sinks stepbuildYamlemitsserializer_inas nested block,on_erroronly when non-default,serializer_outbefore sinks block
Wizard — UX improvements
-
“New Pipeline” toolbar button replaced with split btn-group: Wizard YAML (direct editor) - Template deploy correctly loads template YAML into editor (fixed
window._editorYamlpropagation) - YAML diff now uses
reqText()helper inapi.js— fixes JSON parse error on raw YAML version fetch - “Advanced: open blank YAML editor” link properly clears editor state
Connector Test — full coverage
test_connection()added to all remaining connectors:amqpsource+sink (TCP probe),s3source+sink (head_bucket/list_buckets),gcssource+sink (get_bucket),azure_blobsource+sink (get_account_information),ves(HTTP HEAD),websocketsource+sink (TCP probe),prometheus_rw(local listener check),webhook(local listener check),corba(TCP probe on corbaloc)_extract_host/_extract_portTCP fallback inconnectors.pynow parsesurl/base_urlfields for URL-based connectors
Helm: pre-mounted connector key files
- New
keyssection invalues.yaml:secretName/mountPath— pre-mounts a single Kubernetes Secret at/secrets/on every pod - Quickstart commands documented inline (create, rotate, reference in pipeline YAML)
docs/roadmap_1.2.0.md: key upload API added to roadmap
Fixed
- Pipeline status on startup (cluster mode): non-owning nodes now set
status="scheduled"for interval/cron pipelines instead of leaving them stuck at"stopped";_rebalancerelease also sets"scheduled"instead of"stopped" - SPA routing:
router.init()called unconditionally beforecheckAuth()— hashchange listener always registered - Templates modal: button uses
data-bs-toggle/data-bs-target(notnew bootstrap.Modal().show()) — fixes silent failure in Vite ESM context; server accepts bothsink:(singular) andsinks:(list) in dry-run - Templates view button: inline YAML preview panel (view-switcher) inside modal — no nested Bootstrap modal
- Scheduled badge:
.badge-scheduledCSS (yellow) + dot color added tostyle.css - Password change: Settings page shows Change Password card when logged in
- Settings layout: 3-column grid (col-4 each), no max-width cap
- Pipeline export: download YAML button (↓) added to Actions column
- Detail page tabs: isolated tab panel rendering — fixes DOM corruption when switching Runs/Versions/Config tabs
- YAML diff modal:
bootstrap is not definedindetail.js— addedimport * as bootstrap from 'bootstrap'(Vite ESM modules don’t sharewindow.bootstrapreliably) - Version history table: Diff and Rollback buttons now show text labels alongside icons
- Enrich transform missing file:
_load_lookup()warns and returns empty dict instead of raisingTransformError— allows dry-run to succeed for pipelines with runtime-resolved lookup paths - All 20 bundled pipeline templates pass dry-run: validate rules format (
field: required→{required: true}), empty defaults (${VAR:-}→ named placeholder),seconds:→interval_seconds:,add_fieldformat fixes,:-placeholderdefaults for bare${VAR}env vars test_connectionport defaults: syslog=514, snmp_trap=1162; REST connector uses 443/80 based on scheme; sftp/ftp/snmp_poll/gnmi use connector-specific defaults- Helm fsGroup + key file permissions:
securityContext.fsGroup: 1000on pod spec;defaultMode: 0440on keys Secret volume →root:tramownership, readable by tram user without world-read
1.1.0 — 2026-03-29
Added
Pipeline Wizard
- 5-step UI wizard (Name → Source → Transforms → Sinks → Review) for creating pipelines without writing YAML
- Client-side YAML assembly from wizard state; final step shows live preview and sends to Editor or saves directly
- Accessible from Pipelines page toolbar via “+ New Pipeline” button (Bootstrap modal)
- Server validates both
sink:(singular) andsinks:(list) in template dry-run
Live Metrics Dashboard
GET /api/stats— per-pipeline aggregated stats (records in/out, error rate, avg duration) for the last hour- Dashboard page extended with 10-second polling metrics table and Canvas sparkline graphs per pipeline
- Dialect-aware SQL aggregation:
EXTRACT(EPOCH ...)PostgreSQL,TIMESTAMPDIFFMySQL,juliandaySQLite
Alert Rules UI
- Alert rules CRUD in Pipeline Detail page:
GET/POST/PUT/DELETE /api/pipelines/{name}/alerts - YAML mutation approach: rules written back into pipeline YAML config and persisted
- Alert modal uses
import * as bootstrap from 'bootstrap'(notwindow.bootstrap) for Vite ESM compatibility
Connector Test
POST /api/connectors/test— test connectivity for a connector config; TCP fallback for unknown connector typesPOST /api/connectors/test-pipeline— test all source and sink connectors in a pipeline YAMLConnectorTestMixinbase class intram/core/base.py; all connectors with network access implementtest_connection()
Pipeline Templates
GET /api/templates— returns list of bundled pipeline YAML templates frompipelines/directory- Templates tab in Pipelines page: browse, preview, and load any template into the Editor
- View YAML inline in modal (no nested Bootstrap modal)
- 20 bundled templates covering SFTP, Kafka, REST, SNMP, Syslog, S3, OpenSearch, InfluxDB, ClickHouse, Protobuf, multi-format fanout, webhook alarm, and more
AI Assist
POST /api/ai/suggest—mode=generate(create pipeline from description) ormode=explain(explain existing YAML)GET /api/ai/status— returns configured provider/model and whether AI is available- Configurable via env:
TRAM_AI_API_KEY,TRAM_AI_PROVIDER(openai/anthropic),TRAM_AI_MODEL,TRAM_AI_BASE_URL - Editor page “AI Assist” button with textarea for prompt; result inserted into editor
Password Change
POST /api/auth/change-password— changes password for authenticated user; stored inuser_passwordsDB table (sha256+salt hash)user_passwordstable:(username, password_hash, updated_at); upsert dialect-aware (SQLite/PostgreSQL vs MySQL)- Settings page shows “Change Password” card when logged in
Helm: pre-mounted connector keys
keys.secretName/keys.mountPathinvalues.yaml— pre-mounts a single Kubernetes Secret at/secrets/on every podsecurityContext.fsGroup: 1000+defaultMode: 0440— key files areroot:tramowned, readable by tram user without world-read- Zero-restart key rotation: updating Secret contents propagates via kubelet (~60s); adding a new Secret mount requires rolling restart
- Quickstart docs in
values.yamlkeys:section
Changed
helm/Chart.yaml: version →1.1.0;pyproject.toml: version →1.1.0- Settings page layout: 3-column grid (col-4 each) with no max-width cap
- Pipeline export: download YAML button (↓) added to Actions column in pipelines list
- Scheduled badge:
.badge-scheduled(yellow) CSS added tostyle.css tram-ui/src/pages/detail.js: SPA router always registers hashchange listener unconditionally; Templates button moved to Pipelines page toolbar
1.0.9 — 2026-03-25
Added
Shared RWX storage for schemas and MIBs (cluster mode)
- New
sharedStorageHelm section: singleReadWriteManyPVC (tram-shared) mounted at/sharedon every pod TRAM_SCHEMA_DIRandTRAM_MIB_DIRauto-pointed to/shared/schemasand/shared/mibswhensharedStorage.enabled=true- Schemas/MIBs uploaded via the UI are now instantly visible to all replicas — no session pinning required
helm/kind/nfs-provisioner.yaml: deploys kubernetes-sigs NFS Ganesha server + external provisioner (registry.k8s.io/sig-storage/nfs-provisioner:v4.0.8) in kind clusters; creates StorageClassnfs-rwx- Supported RWX storage classes documented in
values.yaml:nfs-rwx(kind),efs-sc(AWS),azurefile(Azure),filestore-rwx(GKE),longhorn-rwx
Changed
persistence.enableddefaults tofalsein cluster-modevalues.yaml— per-pod/dataPVCs are unnecessary when PostgreSQL +sharedStorageare both active- Removed
sessionAffinity: ClientIPworkaround from Service (was pinning browsers to a single pod to paper over per-pod schema visibility; no longer needed)
1.0.8 — 2026-03-25
Added
Browser user authentication
TRAM_AUTH_USERSenv var: comma-separatedusername:passwordpairs for UI logintram/api/auth.py: HMAC-SHA256 session tokens (8-hour TTL, invalidated on restart)POST /api/auth/login— returns{"token": "...", "username": "..."}on valid credentialsGET /api/auth/me— returns current user from Bearer token (401 if unauthenticated)APIKeyMiddlewareextended: accepts bothX-API-Key(machine clients) andBearertoken (browser users);/api/auth/loginadded to exempt set- Login overlay in tram-ui: full-screen login page shown when
TRAM_AUTH_USERSis configured; 8-hour token stored inlocalStorage; logout button in topbar helm/values.yaml: newauthUserskey (injected asTRAM_AUTH_USERS); recommended to useenvSecretfor production
Multi-file upload (schemas & MIBs)
- Schema and MIB upload zones now accept
multiplefiles; uploads proceed sequentially with per-file progress hints - Drop zone text updated to “Drop files here”
Dashboard shortcuts
- “Manage →” and “+ New” buttons on the Active Pipelines card navigate directly to the Pipelines and Editor pages
Settings — restore base URL
- Reset button (↺) next to the base URL input restores to
window.location.origin(same-origin default) - Removed duplicate “Reload Pipelines” button from Settings (already available on the Pipelines page)
PostgreSQL subchart (Helm)
- Bitnami PostgreSQL added as optional dependency (
postgresql.enabled=trueinvalues.yaml) - When enabled,
TRAM_DB_URLis auto-wired aspostgresql+psycopg2://<user>:<pass>@<release>-postgresql/<db>; no manualTRAM_DB_URLneeded values.yaml:postgresql.auth(username/password/database) andpostgresql.primary.persistence.size- Combined with
replicaCount>1+clusterMode.enabled=truefor a fully self-contained HA cluster
Sample pipeline on install
values.yamlships with asample-healthpipeline (interval 60 s, no-op source, writes status field to/tmp/tram-sample) so a fresh install has a visible running pipeline immediately
Changed
pyproject.toml,helm/Chart.yaml: version →1.0.8tram-ui/package.json,index.html: version badge →v1.0.8helm/values.yaml:replicaCount: 3,clusterMode.enabled: true,postgresql.enabled: true(kind dev-cluster deployment defaults)helm/values-template.yaml: new clean-defaults reference file (ClusterIP, replicaCount:1, postgresql:false,OWNER/tramplaceholder)tram/core/config.py: addedauth_usersfield
1.0.7 — 2026-03-24
Added
tram-ui — Bootstrap 5 web UI
- New
tram-ui/Vite + Vanilla JS project: fully self-contained frontend (no CDN), suitable for Docker embedding - Bootstrap 5.3 dark theme base; all custom styles in
src/style.cssvia CSS custom properties for full dark/light mode support - Hash-based SPA router (
#dashboard,#pipelines,#runs, etc.) with lazy pageinit()loading - Dashboard: stat cards (total/running/errors/records-out), Active Pipelines table with inline stop/play actions, Recent Runs table
- Pipelines: live table with search + status/type filters, per-row start/stop/run/edit/delete, Reload from disk
- Run History: filtered by pipeline/status/date, expandable error rows, CSV export
- Pipeline Detail: summary cards (source/sinks/schedule/transforms/error policy), run history with filters, Runs/Versions/Config tab switching, version rollback
- Pipeline Editor: YAML editor with Tab-key indent,
new-pipeline.yamltemplate for new pipelines, loads existing YAML for edits, Dry Run with inline result panel, Save (create/update) - Schemas: schema file list, drag-and-drop upload zone with subdirectory support, per-row delete
- MIB Modules: compiled MIB list, drag-and-drop
.mibupload, bulk download from mibs.pysnmp.com, per-row delete - Cluster: accordion node list from
/api/daemon/status, online/offline status dots, pipeline assignment per node - Plugins: accordion with Sources 24 / Sinks 20 / Serializers 10 / Transforms 20
- Settings: connection form (base URL, API key, poll interval), Save/Test Connection, Daemon Status table, Reload Pipelines
- Health poller: 10s interval, sidebar dot + topbar hover card show daemon online/offline state, version, scheduler, DB status
- Dark/light mode toggle persisted in
localStorage; all custom CSS uses CSS variables with full light-mode palette - Shared
utils.js:relTime,fmtDur,fmtNum,statusBadge,schedBadge,esc,toast - Full TRAM REST API client in
src/api.js(pipelines, runs, schemas, MIBs, daemon, health, meta, plugins) - Build:
npm run build→ self-containeddist/(~82 KB gzipped total)
Image — UI embedded in daemon
- Multi-stage Dockerfile: new
ui-builderstage (node:20-alpine) runsnpm ci && npm run build; builtdist/copied to/uiin runtime stage - FastAPI mounts
StaticFilesat/uiwhenTRAM_UI_DIRpoints to a valid directory;GET /redirects to/ui/ /ui/*and/exempt from API key authentication — static assets are publicTRAM_UI_DIR=/uidefault env var; set to empty string to disable UI serving
Helm — dedicated UI Service
- New
helm/templates/service-ui.yaml—Servicenamed{release}-uitargeting the same pod port 8765 via a dedicatedClusterIP:80(orNodePort/LoadBalancer) whenui.enabled=true values.yaml: newui:section —enabled,port,serviceType,nodePort,serviceAnnotationsstatefulset.yaml: injectsTRAM_UI_DIR=""whenui.enabled=falseto suppress static servingNOTES.txt: prints UI port-forward command whenui.enabled=true
Changed
pyproject.toml,helm/Chart.yaml: version →1.0.7tram/api/middleware.py:EXEMPT_PREFIXextended to cover/uiand/(root redirect)
[1.0.6] — 2026-03-13
Added
LICENSEfile (Apache-2.0 full text) added to repository root- Helm
service.snmpTrapPorts(list) replaces the former singleservice.snmpTrapPortscalar — iterate any number of UDP ports for multi-source SNMP trap deployments; each port creates one Service UDP port and one container port; adding/removing ports requireshelm upgrade - docker-compose SNMP trap port driven by
TRAM_SNMP_PORT_1env var (defaulting to1162); additional ports can be added as numbered vars and entries in theports:section
Changed
pyproject.toml: classifier"Development Status :: 3 - Alpha"→"Development Status :: 5 - Production/Stable"; added"License :: OSI Approved :: Apache Software License"classifier;licensefield changed from inline{text = "Apache-2.0"}to{file = "LICENSE"}- Helm
Chart.yaml/values.yamlimage tag →1.0.6
[1.0.5] — 2026-03-13
Added
ndjson serializer
@register_serializer("ndjson")— Newline-Delimited JSON (JSON Lines); each non-empty line is parsed as a JSON object- Arrays flattened into the record stream; scalars wrapped in
{"_value": ...}unlessstrict: true strict: bool = False— raisesSerializerErroron non-object lines when enabledensure_ascii,newlineconfig keys match thejsonserializer for consistency- Covers Kafka consumer output, Filebeat/Fluentd/Vector JSON output, jq streams, and any source that produces one JSON object per line rather than a wrapped array
NdjsonSerializerConfigintram/models/pipeline.py
Per-sink serializer_out override
- Each sink config (
SFTPSinkConfig,LocalSinkConfig,KafkaSinkConfig, … all 20) gains an optionalserializer_out: Optional[SerializerConfig] = Nonefield - When set, that sink uses its own serializer instead of the global
serializer_out - Enables multi-format fan-out from a single pipeline: Avro→Kafka + JSON→local + CSV→SFTP
- Example:
serializer_out: # global default type: json sinks: - type: kafka topic: pm-avro serializer_out: # per-sink override type: avro schema_file: /schemas/pm.avsc - type: local path: /data/output # inherits global → json - type: sftp host: archive.example.com serializer_out: type: csv _build_sinks()now returns a 5-tuple(sink_instance, condition, transforms, sink_cfg, per_sink_ser|None)_write_one_sink()resolves: per-sink serializer → global serializer- Forward-reference resolved with
model_rebuild()for all sink config classes (Pydantic v2 pattern)
serializer_out optional at pipeline level
PipelineConfig.serializer_outchanged from required toOptional[SerializerConfig] = NoneNone→ defaults toJsonSerializer({})at runtime in_build_serializer_out()- Pipelines that write JSON (the vast majority) no longer need to declare
serializer_out:
Changed
tram/models/pipeline.py: serializer section now hasNdjsonSerializerConfig;SerializerConfigunion extended;_SINK_CONFIG_CLASSES+model_rebuild()block added after union definitiontram/pipeline/executor.py:_build_sinks()returns 5-tuple;_write_one_sink()handles 3/4/5-tuples;_build_serializer_out()handlesNoneconfig- Helm
values.yaml/Chart.yaml/image.tag→1.0.5
[1.0.4] — 2026-03-13
Added
Schema Registry consolidation
TRAM_SCHEMA_REGISTRY_URLenv var is now a server-level default for both the schema registry proxy (/api/schemas/registry/*) and the Avro/Protobuf serializer clients — no need to repeat the URL in every pipeline YAMLTRAM_SCHEMA_REGISTRY_USERNAME/TRAM_SCHEMA_REGISTRY_PASSWORDenv vars — server-level auth defaults for registry serializers; pipeline YAML fields (schema_registry_username,schema_registry_password) act as per-pipeline overridesAppConfig: three new fields —schema_registry_url,schema_registry_username,schema_registry_password(all from env)AvroSerializerandProtobufSerializer:registry_urlnow resolves fromconfig.get("schema_registry_url") or os.environ.get("TRAM_SCHEMA_REGISTRY_URL"); same fallback forregistry_username/registry_password; credentials forwarded toSchemaRegistryClient
Schema Registry proxy
GET/POST/PUT/DELETE /api/schemas/registry/{path}— transparent reverse proxy toTRAM_SCHEMA_REGISTRY_URL; proxies all headers and query params; returns 503 when env var is not set- Route registered before the
/{filepath:path}catch-all so it resolves correctly
Pipeline management
PUT /api/pipelines/{name}— update/replace a registered pipeline’s YAML config in-place (stops → re-registers → restarts if enabled)
ClickHouse connector
@register_source("clickhouse")— query ClickHouse usingclickhouse-driver; configurablequery,database, chunked viachunk_size@register_sink("clickhouse")— insert records into a ClickHouse table;insert_block_sizebatchingClickHouseSourceConfig/ClickHouseSinkConfigintram/models/pipeline.py- New optional extra:
pip install tram[clickhouse](clickhouse-driver>=0.2) - Registered in
tram/connectors/__init__.py
REST connector fix (httpx 0.28)
tram/connectors/rest/source.py+sink.py:verify_sslmoved from per-requestkwargsto thehttpx.Client(verify=...)constructor — resolvesTypeError: Client.request() got an unexpected keyword argument 'verify'introduced by httpx 0.28
Example pipelines
pipelines/all-transforms-test.yaml— exercises all 20 transform types in a single webhook pipeline; documents cross-record transform behaviour in stream modepipelines/csv-ingest.yaml— CSV serializer validation via webhookpipelines/xml-ingest.yaml— XML serializer (defusedxml) validation via webhookpipelines/rest-pipeline.yaml— REST source (poll) + REST sink (POST) end-to-endpipelines/rest-echo-receiver.yaml— companion webhook receiver for REST sink looppipelines/proto-device-event.yaml— multi-file Protobuf schema:device_event.protoimportsseverity.proto,location.proto,interface_stats.proto,identity.proto; all compiled in oneprotocinvocation
Changed
docker-compose.yml:TRAM_SCHEMA_REGISTRY_URL: ${TRAM_SCHEMA_REGISTRY_URL:-}env var wired in;1162:1162/udpSNMP trap port exposed- Helm
values.yaml/Chart.yaml/image.tag→1.0.4
[1.0.3] — 2026-03-09
Added
SNMP MIB management
TRAM_MIB_DIRenv var (default/mibs) — global MIB directory; SNMP source/sink connectors auto-prepend it tomib_dirsat startup so OID resolution works without per-pipeline configAppConfig.mib_dirfieldtram mib download <NAMES...> --out <dir>— new CLI command; downloads and compiles MIB modules frommibs.pysnmp.comusingpysmi-lextudio(requirestram[mib])tram mib compileenhanced: now accepts a directory in addition to a single file; all.mibfiles in the directory are compiled in one pass so cross-file imports resolve correctly- MIB management REST API:
GET /api/mibs— list compiled MIB modules inTRAM_MIB_DIRPOST /api/mibs/upload— upload a raw.mibfile and compile it (requirestram[mib])POST /api/mibs/download—{"names": [...]}download+compile frommibs.pysnmp.com(requirestram[mib])DELETE /api/mibs/{name}— delete a compiled MIB module
- Dockerfile: three-stage build — new
mib-builderstage downloads + compilesIF-MIB,ENTITY-MIB,HOST-RESOURCES-MIB,IP-MIB,TCP-MIB,UDP-MIB,IANAifType-MIBfrommibs.pysnmp.comat build time; compiled.pyfiles copied to runtime image; MIB download failures are non-fatal (empty/mibson air-gapped builds) - Helm:
mibPersistencesection — optionalvolumeClaimTemplateat/mibsfor persisting runtime-downloaded MIBs across pod restarts
Schema file management
TRAM_SCHEMA_DIRenv var (default/schemas) — global schema directory for serialization schemasAppConfig.schema_dirfield- Schema management REST API:
GET /api/schemas— list all schema files underTRAM_SCHEMA_DIRrecursively; returnspath,type,size_bytes,schema_file(paste-ready for pipeline YAML)GET /api/schemas/{filepath}— read a schema file’s raw text contentPOST /api/schemas/upload?subdir=<dir>— upload a.proto,.avsc,.json,.xsd,.yaml, or.ymlfile; optionalsubdirfor multi-file proto packages; atomic write (.tmp→ rename)DELETE /api/schemas/{filepath}— delete a schema file
- Path-traversal protection on all schema endpoints (
_safe_joinwithos.path.normpath) - Dockerfile:
/schemasdirectory created at build time,ENV TRAM_SCHEMA_DIR=/schemasset - Helm:
schemaPersistencesection — optionalvolumeClaimTemplateat/schemasso schemas uploaded via the API survive pod restarts
Protobuf serializer improvements
framing: nonemode — each file is a single raw serialized proto message (no 4-byte length prefix); required for Cisco EMS PM binary files- Multi-file proto compile fix:
_compile_proto()now compiles all.protofiles in the same directory in oneprotocinvocation so import statements resolve correctly at Python import time ProtobufSerializerConfig: newframing: Literal["length_delimited", "none"]field (default"length_delimited")- Example pipeline:
pipelines/cisco_pm_proto_to_json.yaml— SFTP binary PM files → protobuf decode →_pm_typedetection → JSON output on SFTP
Dependency
python-multipart>=0.0.9added to core dependencies (required forUploadFilein MIB/schema upload endpoints)mibextra (pysmi-lextudio) now included in the default Docker image
Changed
- Dockerfile:
pip install "${whl}[metrics,postgresql,mysql,snmp,mib]"—mibadded to default installed extras; connector extras (kafka,s3,avro,protobuf_ser, etc.) remain opt-in via a customFROM tram:1.0.3layer - Helm
values.yaml/Chart.yaml/image.tag→1.0.3
Fixed
APIKeyMiddleware:AppConfig.from_env()moved fromdispatch()to__init__()— config is now cached once at startup instead of re-read on every requestRateLimitMiddleware._windows: periodic eviction of idle client entries when dict exceeds 500 keys — prevents unbounded memory growth in long-running daemonstram/core/config.py: all bareint()env var reads replaced with_env_int()helper — raisesValueErrorwith the variable name on invalid input instead of a cryptic Python traceback- CI (
ci.yml): removed deaddevelopbranch trigger; added--cov-fail-under=75coverage gate to unit test step - Release (
release.yml): addedtestjob (ruff + unit + integration) that must pass before Docker image is built and pushed docker-compose.yml:TRAM_DB_PATHreplaced withTRAM_DB_URL: sqlite:////data/tram.db
[1.0.2] — 2026-03-06
Added
SNMPv3 USM support
- New
build_v3_auth()helper intram/connectors/snmp/mib_utils.py: builds a pysnmpUsmUserDataobject from human-readable config; security level auto-detected (noAuthNoPriv / authNoPriv / authPriv) - Auth protocols: MD5, SHA (default), SHA224, SHA256, SHA384, SHA512
- Privacy protocols: DES, 3DES, AES / AES128 (default), AES192, AES256; unknown strings fall back gracefully to SHA / AES128
snmp_pollsource (SNMPPollSource):version: "3"now issues GET/WALK withUsmUserDatainstead ofCommunityData;ContextData(contextName=...)passed whencontext_nameis setsnmp_trapsink (SNMPTrapSink):version: "3"sends traps withUsmUserDatasnmp_trapsource (SNMPTrapSource): v3 config fields accepted and stored; trap decoding is best-effort (falls back to raw hex for encrypted v3 packets — full USM receive engine planned)- New v3 config fields on
SnmpPollSourceConfig,SnmpTrapSourceConfig,SnmpTrapSinkConfig:security_name,auth_protocol,auth_key,priv_protocol,priv_key,context_name
[1.0.1] — 2026-03-06
Added
SNMP Poll enhancements
_polled_at(UTC ISO8601) injected into every SNMP poll record payload andmetadict — timestamp reflects the moment the poll was issuedyield_rows: bool = FalseonSnmpPollSourceConfig: whentrue, yields one record per table row instead of one flat dict for the entire WALK resultindex_depth: int = 0onSnmpPollSourceConfig: controls how the row index is extracted from WALK keys —0= auto (split on first dot, correct for MIB-resolved names such asifDescr.1);>0= last N OID components form the index (for numeric OIDs or composite indexes)- Each per-row record carries
_index(dot-separated compound index string, e.g."1.192.168.1.1") and_index_parts(list of strings, e.g.["1","192","168","1","1"]) for downstream parsing
Changed
Build / versioning
tram/__init__.py:__version__now read from installed package metadata viaimportlib.metadata.version("tram")—pyproject.tomlis the single source of truth; fallback to"0.0.0-dev"when running from an uninstalled source treerelease.yml: tag push (v*) now automatically patchespyproject.toml,helm/Chart.yaml(bothversionandappVersion), andhelm/values.yaml(image.tag) in the ephemeral CI workspace before building — no manual version edits required for future releases
[1.0.0] — 2026-03-06
Added
Security
APIKeyMiddleware: protect all/api/*endpoints withX-API-Keyheader or?api_key=query param;TRAM_API_KEYenv var (empty = auth disabled); health/metrics/webhooks paths always exemptRateLimitMiddleware: sliding-window per-IP rate limiting for/api/*;TRAM_RATE_LIMIT(req/min, 0 = disabled),TRAM_RATE_LIMIT_WINDOW(seconds, default 60)- TLS support: set
TRAM_TLS_CERTFILE+TRAM_TLS_KEYFILEto enable HTTPS via uvicornssl_*params - Helm:
apiKeyandtlssections invalues.yaml; TLS secret volume mount + env vars in StatefulSet
Reliability
- Per-sink retry:
retry_count(int, default 0) andretry_delay_seconds(float, default 1.0) on all 19 sink configs; exponential back-off with jitter; DLQ still receives record after all retries exhausted - Parallel sinks:
PipelineConfig.parallel_sinks: bool = False; fans out to all sinks concurrently viaThreadPoolExecutorwhen true - Circuit breaker:
circuit_breaker_threshold(int, default 0 = disabled) on all sink configs; skips sink for 60s after N consecutive failures; resets on success - Kafka reconnect:
reconnect_delay_seconds,max_reconnect_attemptsonKafkaSourceConfig; outer reconnect loop instream_run - NATS reconnect:
max_reconnect_attempts,reconnect_time_waitpassed tonats.connect() - Chunked reads:
read_chunk_bytesonSFTPSourceConfigandS3SourceConfig; yields file in N-byte chunks
SNMP MIB Integration
- New
tram/connectors/snmp/mib_utils.py:build_mib_view(),resolve_oid(),symbolic_to_oid(),oid_str_to_tuple(),get_mib_view()(cached) SnmpPollConfig+SnmpTrapSourceConfig:mib_dirs,mib_modules,resolve_oidsfields; OIDs resolved to symbolic names in output recordsSnmpTrapSinkConfig:varbinds: list[VarbindConfig]for explicit OID/type/field mapping;symbolic_to_oid()resolves IF-MIB-style names- New
VarbindConfigmodel:oid,value_field,type tram mib compile <source.mib> --out <dir>: CLI command to compile raw MIB files (requirestram[mib])- New optional extra:
tram[mib]=pysmi-lextudio>=1.1
Observability
- OpenTelemetry tracing:
tram/telemetry/tracing.py;init_tracing()+get_tracer();TRAM_OTEL_ENDPOINT+TRAM_OTEL_SERVICEenv vars; no-op fallback when SDK not installed;batch_run()wrapped in"batch_run"span - Kafka lag metric:
tram_kafka_consumer_lag{pipeline,topic,partition}Gauge updated after each message poll - Stream queue depth metric:
tram_stream_queue_depth{pipeline}Gauge updated in threaded stream mode - Run history CSV export:
GET /api/runs?format=csvreturnstext/csvviaStreamingResponse - Enhanced readiness:
GET /api/readybody now includesdb,scheduler,clusterfields; returns 503 if DB or scheduler unavailable - New optional extra:
tram[otel]=opentelemetry-sdk>=1.20, opentelemetry-exporter-otlp-proto-grpc>=1.20
Operations / DX
- Pipeline file watcher:
tram/watcher/pipeline_watcher.py;TRAM_WATCH_PIPELINES=truewatchesTRAM_PIPELINE_DIRfor YAML changes using watchdog; auto-reloads on create/modify, deregisters on delete - Pipeline linter:
tram/pipeline/linter.py; five rules: L001 (source+no sinks), L002 (skip+no DLQ), L003 (stream+workers>1), L004 (batch_size on stream), L005 (email alert+no SMTP); integrated intotram validate tram pipeline init <name>: scaffolds a minimal pipeline YAML to stdout or file- New optional extra:
tram[watch]=watchdog>=3.0
Changed
tram/api/app.py: version"1.0.0", middleware registration, OTel init, pipeline watcher in lifespantram/cli/main.py: all API calls injectX-API-Keyheader whenTRAM_API_KEYis set;validatecalls linterhelm/Chart.yaml,helm/values.yaml: version 1.0.0
[0.9.0] — 2026-03-05
Added
thread_workers — intra-node parallelism
PipelineConfig.thread_workers: int = 1— number of worker threads per pipeline runbatch_run(): whenthread_workers > 1, chunks from the source are submitted to aThreadPoolExecutor(max_workers=thread_workers)so N chunks process concurrently; single- threaded code path unchanged forthread_workers=1stream_run(): whenthread_workers > 1, a boundedQueue(maxsize=thread_workers * 2)decouples the source producer from N worker threads, providing natural backpressurePipelineRunContextis now fully thread-safe: all counter mutations go throughthreading.Lock-protected helper methods (inc_records_in,inc_records_out,inc_records_skipped,record_error,record_dlq)
batch_size — record cap per run
PipelineConfig.batch_size: Optional[int] = None— limits records processed per batch run- Source read loop breaks once
ctx.records_in >= batch_size; remaining source chunks skipped - Works in both single-threaded and multi-threaded modes
- Useful for controlling run duration on large sources (Kafka backlog, large S3 buckets)
on_error: "dlq" — explicit DLQ routing
on_errorLiteral extended with"dlq"value- Model validator raises
ValueErrorifon_error="dlq"is set without adlqsink configured - Runtime behavior identical to
on_error="continue"with DLQ sink present — makes intent explicit
Processed-file tracking
- New DB table:
processed_files (pipeline_name, source_key, filepath, processed_at)— PRIMARY KEY on all three name fields; indexed on(pipeline_name, source_key)for fast lookup TramDB.is_processed(pipeline, source_key, filepath) -> boolTramDB.mark_processed(pipeline, source_key, filepath)— dialect-aware upsert; errors logged and swallowedProcessedFileTrackerwrapper intram/persistence/file_tracker.py— silences DB errors, safe for use in connectorsskip_processed: bool = Falseadded toSFTPSourceConfig,LocalSourceConfig,S3SourceConfig,FtpSourceConfig,GcsSourceConfig,AzureBlobSourceConfig- Source connectors check
is_processedbefore reading and callmark_processedafter successful yield +_post_read PipelineExecutor._build_source()injects_file_trackerinto source config dict whenfile_trackeris present on the executorTramSchedulerandcreate_app()wired to create and passProcessedFileTrackerwhen DB is available
CORBA source connector
@register_source("corba")— DII (Dynamic Invocation Interface) mode; no pre-compiled IDL stubs required- Supports: direct IOR (
ior:) or NamingService resolution (naming_service:+object_name:) operation:names the CORBA operation;args:passes positional scalar arguments via DII- Result normalised to
list[dict]via_corba_to_python()(handles structs, nested sequences) skip_processed: boolsupported viaProcessedFileTracker— invocation key =operation:args_jsonpip install tram[corba](pullsomniORBpy>=4.3)CorbaSourceConfigin Pydantic models withmodel_validatorrequiringiorornaming_service- Plugin key:
corba
Helm: ConfigMap checksum annotation
checksum/configannotation added to the StatefulSet pod template (whenpipelinesvalues are non-empty)- Value:
sha256sumof the renderedconfigmap.yaml— changes when any pipeline YAML changes - Kubernetes detects the pod spec diff and triggers a rolling restart automatically on
helm upgrade
Tests — 62 new tests (test_thread_workers.py ×13, test_batch_size_on_error.py ×10,
test_processed_files.py ×15, test_corba_connector.py ×24); 535 total, all passing
Changed
PipelineExecutor.__init__gainsfile_tracker: ProcessedFileTracker | None = NoneTramScheduler.__init__gainsfile_tracker: ProcessedFileTracker | None = Noneexecutor._build_source()injects both_pipeline_nameand_file_trackerinto source configtram/__init__.__version__→"0.9.0"
[0.8.1] — 2026-03-05
Fixed
Kafka consumer group isolation
KafkaSourceConfig.group_iddefault changed from"tram"(shared across every pipeline) toNone— resolved at runtime to the pipeline name, giving each pipeline its own consumer group- Pipelines that set
group_id:explicitly in YAML are unaffected - Added explicit
consumer.commit()beforeconsumer.close()— best-effort offset flush on clean shutdown (supplementsenable_auto_commit=Truetimer; no-ops on abrupt kill) - Fallback chain: explicit
group_id→ pipeline name →"tram"(if no pipeline name available)
NATS queue group for cluster mode
NatsSourceConfig.queue_groupdefault changed from""(broadcast — all cluster nodes receive every message) toNone— resolved at runtime to the pipeline name (competing consumers, correct for cluster mode where the same pipeline runs on all nodes)queue_group: ""in YAML still works as an explicit broadcast opt-out- Fallback chain: explicit
queue_group(including"") → pipeline name →""(broadcast)
Pipeline name injection
PipelineExecutor._build_source()now injects_pipeline_nameinto the source config dict; connectors can useconfig.get("_pipeline_name")as a safe default for group/queue identifiers
Helm chart
helm/values.yamlimage.tagcorrected from"0.6.0"to"0.8.1"
Tests — 20 new tests (test_kafka_connectors.py ×16, test_nats_connectors.py ×5 new);
473 total, all passing
[0.8.0] — 2026-03-05
Added
StatefulSet self-organizing cluster
tram/cluster/registry.py—NodeRegistry: registers the local node in the shared DB, runs a periodic heartbeat thread, expires stale peers (status='dead'), deregisters on clean shutdowntram/cluster/coordinator.py—ClusterCoordinator: caches live node topology, determines pipeline ownership via consistent hashing:sha1(pipeline_name) % live_node_count == my_position- Ownership uses sorted position in live node list (not static ordinal) — handles non-sequential ordinals gracefully when a node fails (tram-0, tram-2 become positions 0 and 1)
- Safe fallback: if no live nodes in DB (startup race), the node owns all pipelines
detect_ordinal(node_id)helper: extracts ordinal suffix from StatefulSet hostname (tram-2→2)
DB: node_registry table
node_registrytable:node_id, ordinal, registered_at, last_heartbeat, status- New
TramDBmethods:register_node()(dialect-aware upsert),heartbeat(),expire_nodes(),get_live_nodes(),deregister_node() - Cluster mode requires an external DB (
TRAM_DB_URL); SQLite is blocked with a warning
Cluster env vars (AppConfig)
TRAM_CLUSTER_ENABLED— enable cluster mode (default:false)TRAM_NODE_ORDINAL— override ordinal (default: auto-detected from hostname)TRAM_HEARTBEAT_SECONDS— heartbeat interval in seconds (default:10)TRAM_NODE_TTL_SECONDS— seconds before a silent node is marked dead (default:30)
Scheduler: dynamic rebalance
TramSchedulergainscoordinatorandrebalance_intervalparameters- Ownership check in
_schedule_pipeline()— nodes skip pipelines they don’t own - Background
tram-rebalancethread: pollscoordinator.refresh()every N seconds; on topology change calls_rebalance()which starts newly owned pipelines and stops released ones
Cluster API endpoint
GET /api/cluster/nodes— returnscluster_enabled,node_id,my_position,live_node_count,nodeslist; returns{"cluster_enabled": false}in standalone mode
Helm: always-StatefulSet design
helm/templates/statefulset.yaml— always rendered;replicaCount=1standalone,Nclusterhelm/templates/headless-service.yaml— always rendered; headless Service for stable pod DNSdeployment.yamlandpvc.yamlremoved — replaced byvolumeClaimTemplatesin StatefulSetvolumeClaimTemplatesauto-provisionsdata-tram-NPVC per pod — survives pod restarts and rescheduling; PVC stays bound to the same pod across node rescheduleshelm/values.yaml—clusterMode.enabled: falsecontrolsTRAM_CLUSTER_ENABLEDenv varhelm/Chart.yaml— version bumped to0.8.0
Tests — 22 new tests (test_cluster.py); 453 total, all passing
Changed
TramScheduler.__init__gains optionalcoordinator: ClusterCoordinator | Noneandrebalance_interval: intparameters (backward compatible — defaults to standalone behaviour)tram/api/app.pywiresNodeRegistry+ClusterCoordinatorfromAppConfigin lifespantram/__init__.__version__→"0.8.0"
[0.7.0] — 2026-03-05
Added
SQLAlchemy Core DB abstraction
tram/persistence/db.pyrewritten on SQLAlchemy Core — any backend supported viaTRAM_DB_URL- SQLite (default), PostgreSQL (
tram[postgresql]), MySQL/MariaDB (tram[mysql]) all work out of the box TRAM_DB_URLenv var (SQLAlchemy URL); falls back toTRAM_DB_PATH→ SQLite when unset- Connection pooling (
pool_size=5,max_overflow=10,pool_pre_ping=True) for non-SQLite backends sqlalchemy>=2.0added to core dependencies (was previously in[sql]optional only)- New optional extras:
postgresql = ["psycopg2-binary>=2.9"],mysql = ["PyMySQL>=1.1"]
Node identity
AppConfig.node_id— fromTRAM_NODE_IDenv (default:socket.gethostname())node_idstored in everyrun_historyrow — essential for multi-node cluster debuggingTramDB(url, node_id)constructor; node_id auto-stamped on everysave_run()
dlq_count persisted
RunResult.dlq_count: int = 0field added;from_context()carries it fromPipelineRunContextto_dict()now includesdlq_countdlq_countcolumn added torun_historytabletram_dlq_totalPrometheus counter (pipelinelabel) incremented on every DLQ write
Graceful shutdown
TramScheduler.stop(timeout: int = 30)— signals all stream threads, waits for in-flight batch runs viaThreadPoolExecutor.shutdown(wait=True), joins stream threads with timeoutTRAM_SHUTDOWN_TIMEOUT_SECONDSenv var (default30) wired throughAppConfigandlifespan- SIGTERM handler in
daemon/server.pyconverts SIGTERM → SIGINT so uvicorn gets a clean shutdown (critical for Docker / Kubernetes PID 1)
Readiness DB check
TramDB.health_check()executesSELECT 1; returnsTrue/FalseGET /api/readyreturns503when DB is configured but unreachable
Run history pagination
GET /api/runsgainsoffsetandfrom_dtquery paramsTramDB.get_runs(offset, from_dt)—OFFSETclause +started_at >=filterPipelineManager.get_runs()and in-memory fallback both support new paramsTramDB.get_run(run_id)now queries DB directly (previously only searched in-memory deque)
Schema migration
_create_tables()is idempotent:CREATE TABLE IF NOT EXISTS+_add_column_if_missing()helper- Existing v0.6.0 SQLite databases upgraded automatically on first start (adds
node_id,dlq_counttorun_history)
Tests — 25 new tests (test_db_v07.py ×15, test_config_v07.py ×6, test_runresult_v07.py ×4); 431 total, all passing
Changed
TramDB.__init__signature:path: Path→url: str = "", node_id: str = ""(uses SQLAlchemy URL)pipeline_versions.idnow TEXT UUID (generated in Python); fresh databases get UUID ids; existing SQLite databases keep their integer ids (SQLite flexible typing)AppConfiggainsnode_id,db_url,shutdown_timeoutfields (from env:TRAM_NODE_ID,TRAM_DB_URL,TRAM_SHUTDOWN_TIMEOUT_SECONDS)
[0.6.0] — 2026-03-05
Added
Dead-Letter Queue (DLQ)
PipelineConfig.dlq: Optional[SinkConfig]— any sink type can serve as DLQ; receives failed records as JSON envelopes- Envelope schema:
{_error, _stage, _pipeline, _run_id, _timestamp, record, raw}whereraw(base64) is only present for parse-stage failures - Three failure stages captured:
parse(serializer_in failed),transform(global or per-sink transform raised),sink(sink.write() raised) - Per-record transform isolation: global transforms applied record-by-record; a single bad record no longer aborts the entire chunk
- DLQ write errors are logged and swallowed — never propagate to main pipeline
PipelineRunContext.dlq_counttracks how many records were DLQ’d in a run
Per-Sink Transform Chains
- Each sink config gains
transforms: list[TransformConfig](default empty) - Applied after global pipeline transforms and after condition filtering, before serializing for that specific sink
- Sink transforms are independent: different sinks can reshape the same records differently
- Sink transform failures route to DLQ (if configured) and skip that sink; other sinks continue
_build_sinks()now returnslist[tuple[BaseSink, condition, list[BaseTransform]]]
Alert Rules
-
AlertRuleConfigmodel:condition(simpleeval),action(webhookemail), webhook_url,email_to,subject,cooldown_seconds(default 300) PipelineConfig.alerts: list[AlertRuleConfig]AlertEvaluatorintram/alerts/evaluator.py— evaluated after every batch run- Alert condition namespace:
records_in,records_out,records_skipped,error_rate,status,failed,duration_seconds - Cooldown persisted in new SQLite
alert_statetable — survives daemon restarts - Webhook action:
httpx.POSTwith full run payload; email action:smtplibSTARTTLS - SMTP configured via env vars:
TRAM_SMTP_HOST/PORT/USER/PASS/TLS/FROM - All action errors logged and swallowed
PipelineManageracceptsalert_evaluator: AlertEvaluator | None;AlertEvaluator(db=db)instantiated increate_app()
Helm Chart (helm/)
Chart.yaml— apiVersion v2, version 0.6.0values.yaml— image, replicaCount (fixed at 1), service, persistence (SQLite PVC), env, envSecret, pipelines ConfigMap, resources, nodeSelector, tolerations, affinity, podAnnotations, serviceAccount- Templates:
statefulset.yaml,service.yaml,headless-service.yaml,configmap.yaml,serviceaccount.yaml,_helpers.tpl,NOTES.txt - Storage managed via
volumeClaimTemplates(introduced in v0.8.0; v0.6.0 useddeployment.yaml+pvc.yaml)
GitHub Actions
.github/workflows/ci.yml— triggers on push tomain/developand all PRs; runs ruff + pytest on Python 3.11 and 3.12.github/workflows/release.yml— triggers onv*tags; builds multi-arch Docker image (linux/amd64 + linux/arm64) →ghcr.io/{owner}/trishul-ram:{semver}; packages + pushes Helm chart →oci://ghcr.io/{owner}/charts/trishul-ram
SQLite
- New
alert_statetable:(pipeline_name, rule_name, last_alerted_at)primary key TramDB.get_alert_cooldown()/set_alert_cooldown()methods
Tests — 35 new tests (test_dlq.py ×11, test_sink_transforms.py ×8, test_alerts.py ×16); 406 total, all passing
Changed
tram/models/pipeline.py— Transforms section moved before Sinks section to avoid Pydantic v2 forward-reference issues withlist[TransformConfig]on sink classes_build_sinks()return type widened to 3-tuple(BaseSink, condition | None, list[BaseTransform])
[0.5.0] — 2026-03-03
Added
Conditional Multi-Sink Routing
sinks: list[SinkConfig]replacessink: SinkConfig(backward compat: singularsink:auto-wrapped by model_validator)- Per-sink
condition: Optional[str]— simpleeval expression evaluated per record; sink is skipped if no records match - Catch-all sink (no condition) receives all records
rate_limit_rps: Optional[float]onPipelineConfig— token-bucket rate limiter across all sink writes
SQLite Persistence (tram/persistence/db.py)
TramDBwrapssqlite3; DB at~/.tram/tram.db(or$TRAM_DB_PATH)- Tables:
run_history(persistsRunResult),pipeline_versions(auto-saved on register) PipelineManageracceptsdb: TramDB | None;record_run()persists to SQLite;get_runs()queries SQLite- API:
GET /api/pipelines/{name}/versions,POST /api/pipelines/{name}/rollback?version=N - CLI:
tram pipeline history <name>,tram pipeline rollback <name> --version N
Prometheus Metrics (tram/metrics/registry.py)
- Counters:
tram_records_in_total,tram_records_out_total,tram_records_skipped_total,tram_errors_total(labeled bypipeline) - Histogram:
tram_chunk_duration_seconds - All metrics are no-ops when
prometheus_clientis not installed GET /metricsendpoint (503 if not installed)- New optional extra:
pip install tram[metrics]
Webhook Source (tram/connectors/webhook/source.py)
@register_source("webhook")— receives HTTP POSTs forwarded from/webhooks/{path}on the daemon port- Module-level
_WEBHOOK_REGISTRYbridges FastAPI router → source generator - Optional
secretforAuthorization: Bearervalidation - New API router:
POST /webhooks/{path}→ 202 Accepted / 404 / 401
WebSocket Connector (tram/connectors/websocket/)
@register_source("websocket")— background thread + asyncio loop + SimpleQueue bridge; auto-reconnect@register_sink("websocket")—asyncio.run()connect/send/close per write- Optional dep:
websockets>=12.0; new extrapip install tram[websocket]
Elasticsearch Connector (tram/connectors/elasticsearch/)
@register_source("elasticsearch")— search + scroll API@register_sink("elasticsearch")—helpers.bulk()withindex_templatetoken substitution- Optional dep:
elasticsearch>=8.0; new extrapip install tram[elasticsearch]
Prometheus Remote-Write Source (tram/connectors/prometheus_rw/source.py)
@register_source("prometheus_rw")— Snappy-decompress + protobufWriteRequest→list[dict]- Reuses WebhookSource global registry (path-routed via daemon)
- Optional dep:
protobuf>=4.25,python-snappy>=0.7; new extrapip install tram[prometheus_rw]
Schema Registry (tram/schema_registry/client.py)
SchemaRegistryClient— Confluent-compatible REST API (also Apicurio); in-memory cache by schema_idencode_with_magic(schema_id, payload)/decode_magic(data)— Confluent magic-byte\x00+ 4-byte BE ID framing- Avro serializer gains
schema_registry_url/subject/id+use_magic_bytesconfig - Protobuf serializer gains same registry config
New Pydantic Models
- Sources:
WebhookSourceConfig,WebSocketSourceConfig,ElasticsearchSourceConfig,PrometheusRWSourceConfig - Sinks:
WebSocketSinkConfig,ElasticsearchSinkConfig - Serializers:
AvroSerializerConfigandProtobufSerializerConfigextended with registry fields
Tests — 49 new tests; 371 total, all passing
[0.4.0] — 2026-03-03
Added
New Serializers
avro— fastavro read/write; requirespip install tram[avro]parquet— pyarrow read/write; requirespip install tram[parquet]msgpack— msgpack pack/unpack; requirespip install tram[msgpack_ser]protobuf— runtime .proto compilation via grpcio-tools; length-delimited framing; requirespip install tram[protobuf_ser]
New Source Connectors
mqtt— paho-mqtt subscriber; TLS support; reconnect on dropamqp— pika consumer; prefetch, auto-ack configurablenats— nats-py subscriber; queue groups; credentials filegnmi— pygnmi subscription (telemetry streaming)sql— SQLAlchemy; chunked readsinfluxdb— influxdb-client Flux queryredis— list LPOP or stream XREAD modesgcs— google-cloud-storage; blob listing + streamingazure_blob— azure-storage-blob; container listing + streaming
New Sink Connectors
amqp— pika publisher to exchange/routing-keynats— nats-py publishersql— SQLAlchemy insert/upsertinfluxdb— line-protocol writeredis— list RPUSH, pubsub PUBLISH, or stream XADDgcs— google-cloud-storage blob uploadazure_blob— azure-storage-blob upload
New Transforms
explode— expand a list field into multiple rowsdeduplicate— remove duplicate rows by key fieldsregex_extract— extract named capture groups from a string fieldtemplate— render Jinja-style{field}string templatesmask— redact, hash, or partial-mask sensitive fieldsvalidate— schema validation withon_invalid: drop|raisesort— sort records by field listlimit— keep only first N recordsjmespath— JMESPath field extractionunnest— lift a nested dict field to top level
Tests — 322 total, all passing
[0.3.0] — 2026-03-03
Added
New Connectors
ftpsource + sink — ftplib; move/delete after read; passive modes3source + sink — boto3; endpoint_url override for S3-compatible storessyslogsource — UDP/TCP listener; RFC 3164/5424 parsingsnmp_trapsource + sink — pysnmp trap receiver / sendersnmp_pollsource — GET/WALK OID pollingvessink — ONAP VES event batch sender; auth types: none/basic/beareropensearchsource (scroll) added alongside existing sink
Tests — 198 total, all passing
[0.2.0] — 2026-03-03
Added
New Transforms
flatten— recursive dict flattening with configurableseparator,max_depth, andprefixtimestamp_normalize— normalizes heterogeneous timestamps to UTC ISO-8601aggregate— groupby + sum/avg/min/max/count/first/lastenrich— left-join records with a static CSV or JSON lookup file
New Connectors
localsource + sink — reads/writes local filesystem filesrestsource + sink — HTTP polling source and POST/PUT sink (httpx)kafkasource + sink — KafkaConsumer/Producer; SASL/SSL supportopensearchsink — bulk-indexes records via opensearch-py
Tests — 124 total, all passing
[0.1.0] — 2026-03-03
Added
Core
tram.core.exceptions—TramErrorhierarchytram.core.context—PipelineRunContext+RunResult+RunStatustram.core.config—AppConfigfrom environment variablestram.core.log_config— JSON-structured logging
Plugin Interfaces — BaseSource, BaseSink, BaseTransform, BaseSerializer
Plugin Registry — @register_* decorators + get_*() lookups + list_plugins()
Pydantic Models — PipelineConfig with discriminated unions; ScheduleConfig
Serializers — json, csv, xml
Transforms — rename, cast, add_field, drop, value_map, filter
Connectors — sftp source + sink
Pipeline Engine — loader.py, executor.py (batch/stream/dry-run), manager.py
Scheduler — TramScheduler (APScheduler batch + threads stream)
REST API — FastAPI on port 8765; health, pipelines, runs, daemon endpoints
CLI — Typer; direct + daemon-proxy commands
Tests — 69 total, all passing