Live streams
Collector: recording
Record each source always, by trigger or manually, mark moments, and keep every value for years as encrypted day files with 1-minute and 1-hour summaries.
Everything the collector stores from a stream is a recording. Each source says how it records with record:: always (the default), by trigger (from a start condition to a stop condition), or manual (only when someone presses Record in the app). Sources that record by themselves keep their values for years as encrypted day files with 1-minute and 1-hour summaries. This page is for the OT engineer who sets it up and the IT reviewer who approves the extra firewall host. Installation and the rest of collector.yaml are on the Site collector page.
How a source records
Set record: on each source, and the recording: block once for the whole collector:
api_key: env:KYMOSCOPE_API_KEY
state_dir: state
health:
stream: Customer A collector health
sources:
- name: Customer A DCS
type: opcua
url: opc.tcp://dcs-gw:4840
tags: tags.csv
record: always # the default: everything the source sends
mark_tag: MarkButton # optional: a rise of this channel marks the stream
- name: Customer A test rig
type: udp
bind: 127.0.0.1:5005
layout: rig_layout.yaml
record:
trigger: # only from start to stop
start: Speed > 5
stop: Speed < 1
start_after: 5s # optional: the start must hold this long
stop_after: 2min # optional: the stop must hold this long
- name: Customer A lab bench
type: modbus
host: 192.168.1.20
map: registers.csv
record: manual # stored only when someone presses Record in the app
recording: # for every source that records by itself (always or trigger)
timezone: Europe/Madrid # recordings and days are cut at its midnight (default UTC)
split_gap: 30min # no value for this long ends a recording (default 30min)
# where: own # kymoscope | own; omit to follow the organization's choice
# raw_years: 2 # raw values kept this many years (your plan may allow fewer)
# close_after_min: 15 # a day is uploaded this many minutes after its midnight
# keep_local_days: 7 # the local copy stays this many days after the upload
# max_local_gb: 50 # cap of each source's local day spool
# today_every_min: 15 # today's summaries are uploaded this often (0: only at midnight)| record | What is stored |
|---|---|
always | Everything the source sends, day and night. The default when record is left out. |
trigger | Only from the moment the start condition is true until the stop condition is true. See Record by trigger. |
manual | Nothing by the collector. The stream is recorded only while someone presses Record in the app, and those recordings are kept 90 days. |
The live stream always carries every value, whatever record says: it only decides what is stored. Restart the collector after changing it.
Record by trigger
A condition is a channel, a comparison (> >= < <= == !=) and a number, for example Speed > 5. To combine several, write all: (every one true) or any: (at least one) with a list:
record:
trigger:
start:
all: # every condition true (any: at least one)
- Speed > 5
- Gear >= 1
stop: Speed < 1
stop_after: 90s| Key | Meaning and default |
|---|---|
start | Required. Recording starts when it becomes true. |
stop | Required. Recording stops when it becomes true. |
start_after | Optional. The start must hold this long before recording starts; a shorter blip starts nothing. Values from the wait are not stored. |
stop_after | Optional. The stop must hold this long before recording stops; a shorter dip keeps it going. Values from the wait are stored. |
Durations are written 500ms, 30s, 2min, 1.5h, or a plain number of seconds. Each start to stop is one recording, however short the pause before the next start (collector 0.6.1 or later; the pauses are kept in trigger-runs.jsonl beside the source's days for 31 days). In the app's Cloud window the stream shows Trigger.
Marks
Every stream of a source has a channel named mark. It is 0 from the stream's start and 1 for a single sample when the stream is marked, so each mark is a spike you can chart, alert on or start a cycle on. A mark flags a moment, not a stretch: look before and after it.
To mark from the process, set mark_tag on the source to one of its channels, for example an operator push-button wired to the DCS or PLC. Each rise of that channel (from 0 or below to above 0) marks the stream.
Turn it on
Sources that record always or by trigger need three things:
- The Historian on your organization. Enterprise includes it (5 years of raw data, 5,000 channels, and the collector's cycle rules); on other plans ask Kymoscope to turn it on. We set the years of raw data and the number of channels your contract includes. Without it, a source with no
recordline records manually (the log says so once, the health stream showshistory_not_in_plan). A source withrecord: alwaysor a trigger written keeps its days on the collector's disk until the Historian is on. - An end-to-end encrypted project. The streams must belong to a project with end-to-end encryption. For any other project the collector uploads nothing, the log says why, and the health stream shows
history_needs_e2ee = 1. - The history extra. Install
pip install "kymoscope-collector[history]"(the Docker image already includes it). Without it, a source with norecordkey records manually and the log says so; a source withrecord: alwaysortriggerwritten out stops the start with a sentence naming the extra.
The recording block
Every key is optional and applies to every source that records by itself. Leave the block out to take the defaults.
| Key | Meaning and default |
|---|---|
recording.timezone | IANA zone of the site, for example Europe/Madrid. Default UTC. Recordings and days are cut at its midnight. It cannot change once a day of that source is stored. |
recording.split_gap | A duration. A span with no value at all this long ends a recording, and the next value starts a new one; the span is stored as a gap. Default 30min; 0 never splits. |
recording.where | kymoscope or own. Leave it out to follow the organization: its own bucket when an admin set one up, else Kymoscope storage. See Your own storage. |
recording.raw_years | Years the raw day files are kept, 1 to 30. Default 1. The server keeps the lower of this and what your plan allows. |
recording.close_after_min | Minutes after midnight before a day is built and uploaded. Default 15. |
recording.keep_local_days | Days the local copy of a day stays after it was uploaded. Default 7, at least 1. |
recording.max_local_gb | Cap of each source’s local day spool in GB. Default 50. See Disk on the collector host. |
recording.today_every_min | Minutes between uploads of today’s 1-minute and 1-hour summaries, so today can be opened in the app before midnight. Default 15; 0 uploads them only when the day closes. Any other value must be at least 5. |
Coming from history:
The history: section of earlier collectors is gone, and a configuration that still has it stops the start. Move its keys under recording:. gap_s becomes split_gap; a source left out of history.sources gets record: manual, and so does every source where enabled: false was set. Stored days stay as they are.
# before (refused now):
# history:
# timezone: Europe/Madrid
# gap_s: 600
# sources: [Customer A DCS]
recording:
timezone: Europe/Madrid
split_gap: 10min # was gap_s: 600
sources:
- name: Customer A DCS # records always (the default)
...
- name: Customer A lab bench # was not in history.sources
...
record: manualWhat it keeps
- Every value at the rate the source delivers it, exactly as the live stream carries it (for
trigger, while a recording is open). Nothing is resampled or deadbanded. - Per source and per site-local day (midnight to midnight in
recording.timezone): one raw day file with every value, and two rollup files with a summary per channel for every minute and every hour (min, max, mean, count and last value). - Spans with no value at all longer than
split_gapare stored as gaps, so a missing hour reads as missing, not as a flat line. - Everything is encrypted on the collector before it is uploaded. Kymoscope stores files it cannot read.
The rollups make long ranges fast: a year of a channel is a few thousand hourly points, and the raw file is only needed when you zoom in.
How long it is kept
- Raw day files:
raw_years, up to what your plan allows. - Rollups: 10 years, whatever the raw setting.
- Raising
raw_yearsextends the days already stored. Lowering it applies to new days only: stored days keep the expiry they had. - If the contract ends or is reduced, no stored day is deleted early. Each day stays until the expiry it got when it was uploaded; only new uploads stop.
Keep windows
A keep window holds raw data past its normal expiry, for example around an incident or a batch you may be asked about. Any member of the project can mark one while the Historian is on. A window keeps every whole raw day it overlaps, for as long as the window exists, and covers at most 7 days.
Each organization has 10 keep windows per calendar year included. Beyond that, windows are still created (marking an incident is never blocked) and count as overage on your plan.
Today's data
A day's raw file is built and uploaded after the site's midnight. Until then, the collector uploads today's 1-minute and 1-hour summaries every today_every_min minutes (15 by default), so today can already be read in the app at summary resolution; every value of today arrives once the day closes. How members open, zoom, keep and export a history is on Open history in the app.
Cycles and alerts
A cycles: section next to recording: lets the collector cut batches, shifts and laps, with their phases and limits, 24/7 with no browser open, on sources that record always or by trigger, and alerts: emails when one misses a limit. Both, and how members compare runs against a reference, are on Cycles. The older segments: section still works in this release.
Late data and outages
Every value a source records also goes into a local day spool on the collector host (state_dir/history/<source>/days), one folder per local day. The day is built from that copy, so a network outage only delays the upload: the collector retries every minute, and an interrupted upload resumes where it stopped.
- Values that arrive after a day was uploaded (a
backfill, a buffer catching up, a slow source) make a new version of that day, which replaces the previous one. A day is never shortened by late data. - The local copy stays
keep_local_daysafter the upload, so late values within that time merge from the local copy. After that, the collector downloads the stored day and merges into it. - A day older than its retention is not accepted any more and is skipped (
day_expired).
Disk on the collector host
As a rough guide, 1,000 tags at 1 value per second take about 0.4 GB per day in the local spool. With the default keep_local_days: 7, plan for about 9 days of that (the current day, the day being uploaded and the 7 kept), so about 3.5 GB per 1,000 tags at 1 Hz. Tags that report only on change take much less.
The spool has a cap, recording.max_local_gb (default 50 GB). The collector protects the disk and your data in this order:
- Near the cap, it deletes days that are already uploaded first, oldest first, even before
keep_local_daysis over. - It never deletes a day that is not uploaded yet.
- Over the cap, or with less than
buffer.min_free_mbfree on the disk, it refuses new data for the spool (history_spool_refusedrises). The live stream is not affected. - It skips building a day when the disk does not have room for it, and tries again later (
history_disk_lowis 1).
Days that cannot be uploaded (the Historian is off, the network is down) stay on disk until they are, so a long outage can fill the cap: values refused meanwhile are missing from the recording, not from the live stream. Watch disk_free_gb, history_days_pending and history_spool_mb in the health stream.
Security
- Local spool: encrypted with a key made on the collector host. On Windows that key is protected by DPAPI; on Linux and Docker it is a file only the collector account can read, inside
state_dir. - Day files: sealed with a random history key per source. The collector seals that key under the project key, so only the browsers of project members and the collector itself can open the files. Kymoscope cannot.
- Key rotation: rotating the project key keeps every stored day readable. If the project key is regenerated, the collector re-registers its history key on its next hourly check.
- A new collector sending to a project waits for a project member to approve it before it can upload history, just as for the live stream.
Network
An organization can keep its day files in its own S3-compatible bucket instead, in the cloud or on site. See Your own storage.
Day files go straight from the collector to Kymoscope storage, through short-lived upload links. This adds one outbound host to the two on the Site collector page:
| Outbound host (port 443) | Used for |
|---|---|
<account>.eu.r2.cloudflarestorage.com | Kymoscope storage (Cloudflare R2, EU jurisdiction): uploading day files, and downloading a stored day to merge late data or to verify it. Allow *.eu.r2.cloudflarestorage.com if your firewall or proxy takes wildcards; Kymoscope support gives you the exact host otherwise. |
HTTPS only, outbound only. When proxy is set, these requests go through it like the others.
Commands
# every source that records by itself and the state of each local day
kymoscope-collector history status --config collector.yaml
# upload a day now instead of waiting for midnight (the running collector does it within a minute)
kymoscope-collector history upload --config collector.yaml --source "Customer A DCS" --day 2026-10-20
# download the uploaded day, open it and count the values per channel next to the local copy
kymoscope-collector history verify --config collector.yaml --source "Customer A DCS" --day 2026-10-20The commands and the health channels below keep the name history. --source is the source's name and --day is the site-local day. history upload only marks the day: the running collector (service, Docker or console) picks it up. history status prints one line per local day:
Customer A DCS:
2026-10-19: uploaded v1, local copy deleted
2026-10-20: uploaded v2
2026-10-21: pending: 2 file(s) left
2026-10-22: waiting (open)history verify exits with 1 when the day is not uploaded or does not open, and with 0 otherwise, so a scheduled task can alert on it. The counts it prints let you compare the stored day with the local copy while that copy still exists.
# verify-history.ps1: run daily from Task Scheduler, after the upload (for example at 02:00)
$day = (Get-Date).AddDays(-1).ToString('yyyy-MM-dd')
& kymoscope-collector history verify --config C:\kymoscope\collector.yaml --source "Customer A DCS" --day $day
if ($LASTEXITCODE -ne 0) {
Write-EventLog -LogName Application -Source "Kymoscope" -EventId 1001 -EntryType Error `
-Message "Kymoscope recording day $day is missing or does not open"
exit 1
}#!/bin/sh
# /etc/cron.d/kymoscope-history: 0 2 * * * kymoscope /opt/kymoscope/verify-history.sh
day=$(date -d yesterday +%F)
if ! kymoscope-collector history verify --config /opt/kymoscope/collector.yaml \
--source "Customer A DCS" --day "$day"; then
logger -p user.err "Kymoscope recording day $day is missing or does not open"
exit 1
fiHealth channels
With health.stream set, each source that records by itself adds these channels to the health stream:
| Channel | Meaning |
|---|---|
<source>.history_days_pending | Closed days not uploaded yet. 0 most of the time; above 1 for long means uploads are failing. |
<source>.history_errors | Failed upload attempts since the collector started. The log says why. |
<source>.history_needs_e2ee | 1 while the project is not end-to-end encrypted, so nothing is uploaded. |
<source>.history_spool_errors | Blocks that could not be written to the local day spool (a full disk, for example). The live stream is not affected. |
<source>.history_spool_mb | Size of the local day spool in MB. Compare with recording.max_local_gb. |
<source>.history_spool_refused | Blocks refused for the spool because it was over its cap or the disk was below buffer.min_free_mb. The live stream is not affected. |
<source>.history_spool_key_resets | Spool keys set aside as unreadable (renamed to spool.key.bad) since start. Days spooled under the old key can no longer be read. |
<source>.history_disk_low | 1 while a day could not be built for lack of free disk space; it is retried later. |
<source>.history_storage_waiting | 1 while new days wait for your organization’s own bucket: not tested yet, or the own-storage contract ended. See Own storage. |
Deleting a history
A project lead can delete a source's history in the app. If its collector is still running with that source recording always or by trigger, it creates the history again within an hour and uploads its local days once more. To delete one for good, first set record: manual on the source, restart the collector, then delete the history in the app.
Troubleshooting
The collector log names the source and the day, then the reason. The first two stop the start:
| Message | What to do |
|---|---|
| 'history' was replaced: every source records always by default | The configuration still has a history: section. Move its keys under recording: (see Coming from history:) and start again. |
| record: always needs pyarrow | Install the history extra: pip install "kymoscope-collector[history]", or set record: manual on that source. |
needs_e2ee | The project is not end-to-end encrypted. Move the stream to an encrypted project. Until then each local day is deleted keep_local_days after it ends. |
history_not_in_plan | The Historian is off for your organization (it comes with Enterprise), or its end date has passed. Ask Kymoscope. Days stay on the local disk and upload once it is on. |
channels_quota | The channels of all your sources that record by themselves exceed what your plan includes. Set record: manual on some, narrow their tag lists, or ask for more channels. |
day_expired | The day is older than its retention, so it is skipped for good. history status shows it as skipped. |
tz_locked | recording.timezone was changed after days were stored. Set it back; a source keeps its zone for life. |
| sealed with a history key this collector doesn’t hold | history verify ran on a collector that did not upload that day (a reinstall or another host). The day is fine: run verify on the collector that uploaded it, or open it in the app. |
| waiting for a project member to approve this sender | Approve the collector in the project, as for its live stream. Uploads start at the next check. |