Legacy tracker data migration starts with the fields in the export being inspected. Identify which fields associate conversions with clicks and which links remain observable in those rows. A missing value needs another source before it can be treated as recovered. Keep three inventories: association keys, the meaning of each timestamp, and records with incomplete links.
Switching delivery over is a different problem, and the corpus already covers it: Tracker Migration Without Losing Conversions handles in-flight clicks and postback continuity going forward. Legacy tracker data migration asks the narrower and older question: what leaves the old system as evidence, and when that evidence is good enough to declare a cutover.
Legacy Tracker Data Migration Audits Rows, Not Campaigns
A campaign-level claim like "our history is complete" has no observable referent. Legacy tracker data migration is an inventory exercise over those fields, and each of them lands in one of three classes: carried (present in the export under a documented name), derivable (reconstructible from other kept columns, with the derivation written down), or lost (never stored, or stored where the export cannot reach).
| Item in a conversion row | Where the key lives | What a documented export carries | Class |
|---|---|---|---|
| Click identifier | the tracker's own click id, delivered as the postback parameter subid | a value plus the parameter name it arrived under | carried |
| Click identifier sent under another name | alias mapping in postback settings | the alias table, or nothing about its meaning | derivable, if the config is exported too |
| Transaction identifier | optional tid, used to keep repeated conversions apart | one row per unique tid | carried |
| Payout and its currency | postback parameters, sometimes absent | amount, possibly without a currency | derivable or lost |
| Visit context of the conversion | the click record the postback matched | only when a click key resolved at all | often lost |
Keitaro's documentation states the join requirement plainly: "Without subid and status , the tracker cannot correctly process the postback." (Postback - Keitaro documentation) That single sentence defines the whole key inventory: a conversion row is only as migratable as the click key it was matched on, and the same page warns that a postback can also overwrite click-side values, since parameters such as cost, external_id and creative_id "will overwrite data previously recorded with the click from the traffic source".
Configuration travels or the keys lose their meaning. An alias that mapped clickid to the click key is not data; it is a rule, and a bare numeric column without its rule is an unexplainable value in five years. The click id is what a legacy row uses to claim it belonged to a visit at all, so legacy tracker data migration is measured by how many rows still hold one, not by how many rows the file contains.
Which Time Base a Legacy Row Actually Carries
Timestamps are the field most often migrated wrongly, because the column name promises more than the stored value delivers. A legacy conversion row can record: when the click happened, when the outcome happened at the advertiser, when the tracker received the postback, and when a report aggregated it.
The mismatch is documented, not hypothetical:
In the tracker, conversions are written by click date, but the Conversion Log is built not by click date, but by conversion date: this is the date of receiving the postback in the tracker.
Read together with the export contract - "Conversions are recorded by the date of the first postback received on subid for each unique tid value" - this means a CSV pulled from the conversion log is a receipt-time ledger wearing a conversion label, and legacy tracker data migration inherits that label unless the column is renamed before the first report is rebuilt. Two time bases per row, so the transferability inventory records which one a column holds before any report is rebuilt from it (cross-platform click id reference covers how long each platform's own click key stays resolvable).
| Time base | Written by | Present in a legacy export | Label it as |
|---|---|---|---|
| Click time | the tracker at redirect time | yes, if the click record still exists | visit_time |
| Outcome time at the advertiser | the network or your own site | only if it arrived as a parameter | advertiser_time, often absent |
| Postback receipt time | the tracker when it accepted the request | this is what a conversion log sorts by | receipt_time |
| Report aggregation time | whatever dashboard you kept | only inside that dashboard's own tables | not a row field |
| Visit time of an unlinked conversion | nothing | replaced by receipt time in fallback paths | degraded, must be marked |
Export access and deletion settings limit different parts of the audit. Voluum Traffic Log can retrieve data up to four months old, but each selected report interval is at most seven days. These are lookback and request-range limits, not a storage-retention statement (Traffic log - Voluum documentation). In Keitaro, Stats store term controls automatic statistics cleaning; deleted statistics cannot be restored (Cleaning statistics - Keitaro documentation). Keitaro's Archive separately retains deleted resources such as campaigns and flows for a configurable period, with a default of 60 days. After that, those archived resources cannot be recovered (Archive - Keitaro documentation).
Record each limit separately before decommissioning the source: which dates the export can reach, how large each request may be, when statistics are purged, and which resources remain recoverable from an archive.
Rows With No Link: What the Source Already Lost
Voluum documents a fallback for conversions without a click ID. It attributes the conversion to a campaign rather than a visit, uses the postback timestamp as visit time, and treats each postback as unique without duplicate filtering. Currency defaults to USD unless supplied. The default fallback lacks parts of the visit context, but additional offer, lander, path or flow IDs can associate the conversion with selected funnel elements; custom variables must also be supplied explicitly. Those additions do not reconstruct a full visit (Registering Conversions Without Click IDs - Voluum documentation).
A request that never became a row leaves a different trace. Keitaro logs postbacks it discards: "If the status parameter is missing, the tracker ignores the postback and logs the event." That ignored-request log is evidence about the reporting gap between what a partner sent and what the source recorded, and it sits outside the conversion export entirely (postback troubleshooting checklist works through that boundary during live operation, not during a migration).
Counting them is the part of legacy tracker data migration that cannot be outsourced to an importer, and the inventory has three buckets rather than estimates:
- Unkeyed rows - conversions held without a click key, usually attributed to a campaign or an offer only. Preserve their available campaign or funnel context without presenting it as a reconstructed visit.
- Purged-key rows - a key existed and the click side is gone: retention passed, the server decommissioned, or a dump taken without the click table. The join is gone even though the amount is not.
- Never-recorded outcomes - requests the source ignored or never received. Nothing in the export represents them; only the discard log and the partner's own statement do.
Duplicate handling belongs to the same audit, because tid is what separates a repeated conversion from a re-sent one: the conversion log shows "conversions for one subid with different tid", while a repeated postback with identical parameters does not produce a second S2S send. Duplicate conversions across tracker, network and platform owns that mechanics; here it is only a column to classify.
Side-by-Side Event Feed: A Reproducible Comparison
A side-by-side feed is not a second dashboard. It is a claim you can re-run: the same interval, the same join key, the same time base, and every difference sorted into a named bucket. The column contract comes first, because comparing rows on columns that do not mean the same thing produces a number nobody can defend.
click_key,tid,time_base,time_value,offer_id,lander_id,payout,currency,link_classtime_base is not decoration: it records whether time_value is click time or receipt time, and a legacy tracker data migration that drops it silently merges two ledgers disagreeing by design. link_class holds the bucket from the previous section, so an unlinked row stays visibly unlinked in the new feed instead of inheriting a plausible visit.
click_key alone is not a join key. The conversion log is documented to show "conversions for one subid with different tid", so repeated conversions of one click differ precisely in tid, and a comparison keyed on click_key would collapse them into a single line and report them as agreement. The pair click_key + tid is what the comparison below builds, and it is what the ladder of observations further down calls an accepted row. The pair is glued with a literal #, which is a valid composite key only while neither column contains that character; the guard below therefore refuses such a row instead of letting one string stand for two different pairs.
# save as compare_feeds.sh and run with bash; one closed interval on both sides
set -euo pipefail
TB=receipt_time # the time_base value this comparison agrees on
printf 'click_key,tid,time_base,time_value,link_class\nsubid-1,t-1,receipt_time,2026-08-03T11:20:05Z,carried\nsubid-2,t-1,receipt_time,2026-08-04T09:10:00Z,carried\nsubid-3,t-2,receipt_time,2026-08-05T18:42:11Z,purged_key\nsubid-4,,receipt_time,2026-08-05T19:00:00Z,unkeyed\n' > old_export.csv
printf 'click_key,tid,time_base,time_value,link_class\nsubid-1,t-1,receipt_time,2026-08-03T11:20:05Z,carried\nsubid-2,t-1,receipt_time,2026-08-04T09:15:00Z,carried\nsubid-9,t-7,receipt_time,2026-08-06T07:00:00Z,carried\n' > new_feed.csv
: > excluded_rows.csv # rows the guard refuses to key stay visible here
keyed() { # one ledger -> "click_key#tid,time_value" rows
tail -n +2 "$1" | awk -F, -v tb="$TB" -v bad=excluded_rows.csv '
BEGIN { OFS = "," }
$3 == tb && $1 != "" && $2 != "" && $1 !~ /#/ && $2 !~ /#/ { print $1 "#" $2, $4; next }
{ print >> bad }'
}
join -t, -a1 -a2 -e MISSING -o 1.1,2.1,1.2,2.2 \
<(keyed old_export.csv | LC_ALL=C sort -t, -k1,1) \
<(keyed new_feed.csv | LC_ALL=C sort -t, -k1,1) > joined_rows.csv
awk -F, '{ if ($1 == $2 && $3 == $4) same++
else if ($2 == "MISSING") only_old++
else if ($1 == "MISSING") only_new++
else time_diff++ }
END { printf "joined_rows %d\nsame_key_same_time %d\nonly_in_old_export %d\nonly_in_new_feed %d\nkey_matched_time_differs %d\n",
NR, same+0, only_old+0, only_new+0, time_diff+0 }' joined_rows.csv > bucket_counts.txt
printf 'excluded_unkeyable_rows %d\n' "$(awk 'END { print NR+0 }' excluded_rows.csv)" >> bucket_counts.txt
cat bucket_counts.txtThe example removes the header before sorting and keeps rows rejected by the key guard in excluded_rows.csv. Review that file alongside the matched rows. The script is a limited comparison of the shown fixture; adapt its parsing and key rules to your actual export before using it for a migration.
Expected output for the built-in fixture, six lines:
joined_rows 4
same_key_same_time 1
only_in_old_export 1
only_in_new_feed 1
key_matched_time_differs 1
excluded_unkeyable_rows 1The fixture is a five-column projection of the contract above (offer_id, lander_id, payout, currency carry no weight in the bucket comparison), and its last old-side row is the case the guard handles: subid-4 has no tid, so it never enters the join and joined_rows 4 counts only keyable rows. The refusal is not silent. That row is appended to excluded_rows.csv, and the sixth counter prints how many rows the comparison never reached, which is what step 4 needs to explain. unkeyed and purged_key are the values the contract above assigns to link_class, so an unkeyable row belongs in the same inventory as the four buckets and not in a discarded stream; dropping it in silence is how a legacy tracker data migration reports a clean comparison over a subset nobody named.
Six steps, each with its own artifact:
- Freeze a data snapshot for the historical interval being audited and compare immutable copies. Keep production postback intake running and record late arrivals separately.
- Repeat the export from that same snapshot and compare the files. Investigate any differences in filters, export settings or snapshot selection before using the comparison.
- Run the block above as a script and keep
bucket_counts.txt: six counters on six lines, written to a file rather than photographed on a terminal, withexcluded_rows.csvbeside it. - Explain every bucket by class: purged key, unlinked row, later receipt, filter difference in the new feed, rows the empty-
tidor#guard refused. An unexplained residual is a finding, not rounding. - Recompute the metric you actually report (payout by offer, count by status) from both sides, and compare the metric rather than only the row counts.
- Record what the audit could not reach: any row whose click side had already aged out of the documented export window.
The Cutover Criterion and Its Limits
A defensible criterion is three conditions, each checkable by someone else:
- every bucket in the comparison has a named cause, and the residual is zero rows or an explicitly listed exclusion;
- key coverage is reported per interval and per offer, because a blended figure hides the campaigns where the source had nothing to carry;
- the same comparison re-run after the old system stops receiving traffic reproduces the counts, so the feed - not the export script - is what you now trust.
Coverage percentages have no universal target in legacy tracker data migration: the denominator is your own rows, and any number quoted in a vendor post belongs to that vendor's campaign mix. The ladder of observations keeps the claim honest about its own level.
| Level | Artifact that proves it | What it does not prove |
|---|---|---|
| Sent | the partner or network's outbound log | that the tracker understood the request |
| Received | a line in the tracker's own request log for that call, whichever way its decision went | that the call produced a conversion row, because the log quoted above records a discarded request as tidily as a kept one |
| Accepted | a conversion row keyed on click_key plus tid | that the row reaches a report, or that a later export still reaches it |
| Exported | a reproducible file within the export's documented accessible date range holding that row | that the new feed counts it |
| Counted | the new feed or report showing the row | attribution or bidding results inside an ad platform |
The last row is the real boundary, and product documentation says it in one line: integration records "do not promise attribution or bidding outcomes inside an ad platform" (Way2 Telegram tracking, evidence and boundaries). Whether a destination still accepts history at all is a separate check with its own window, covered for the Telegram contour in Telegram historical backfill feasibility and deliberately not decided here.
Two more limits follow from the inventories above. A migration must preserve the distinction between a full visit and the campaign or funnel context available for an unkeyed conversion. And a dump is not an audit: a full server transfer moves statistics and settings automatically, including the postback key, yet the receiving system still needs someone to say which time base each column holds (Keitaro: transfer to another server).
Equal totals on both sides can still hide a shifted time base, unlinked rows reported as journeys, and duplicates collapsed by a key the new feed invented. Cut over on explained differences, not on matching sums.
Need to review a migration?
Share an export column list and a sanitized example of an unexplained difference. The first consultation is free.
Do not send tokens or passwords.
Sources
Sources
- Postback - Keitaro documentation
- Conversion log - Keitaro documentation
- Cleaning statistics - Keitaro documentation
- Archive - Keitaro documentation
- Transfer Tracker to Another Server - Keitaro documentation
- Traffic log - Voluum documentation
- Registering Conversions Without Click IDs - Voluum documentation
- Way2 Telegram tracking: evidence, boundaries and backfill
Way2 services
