import: time each step of the walk as a Sentry span
Every screen of the walk re-reads the staged file whole, so each step that grows with it is timed;
the spans count cards and bytes but never what a card holds, and the test finds no name in any transaction it finds.
Assisted-by: Claude Opus 5.5 via Claude Code
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
diff --git a/lib/pro_tacts/web/import.rb b/lib/pro_tacts/web/import.rb
index 5174658..8620f29 100644
--- a/lib/pro_tacts/web/import.rb
+++ b/lib/pro_tacts/web/import.rb
@@ -39,6 +39,10 @@ module ProTacts
# the file — a stop between saves that repeated what the list
# beside every card already said.
#
+ # Every screen re-reads the whole staged file, so each step that
+ # grows with it is a span (Web#span) counting cards, never their
+ # content.
+ #
# Under the same identity gate as every other route (web.rb),
# which is also where the arrivals' books come from: a card this
# creates joins everyone's book the way a client's create does, so
@@ -113,25 +117,27 @@ module ProTacts
# here on is a .vcf like any other: the composed file is what
# the walk stages as the import's original and strikes lines
# out of (Import::Monica).
- bytes = Import::Monica.vcf(bytes) if Import::Monica.export?(bytes)
- cards = Import::Vcf.cards(bytes)
+ bytes = span("import.monica") { Import::Monica.vcf(bytes) } if Import::Monica.export?(bytes)
+ cards = span("import.split") { |step| Import::Vcf.cards(bytes).tap { step&.set_data("cards", it.length) } }
rescue Import::Vcf::Invalid => error
return import_screen(notice: error.message)
end
- revised = cards.map { Import::Vcf.read(it).card }
- id = Import::Staged.open(
- original: bytes,
- revised: joined(revised),
- # Named here and not at the end, there being no end to name
- # it at: every Save files its contact under it. Not the
- # importer's to rename either — a name typed before anything
- # has been looked at is a name for nothing, and one typed
- # after would split an import across two groups. What is
- # editable is the membership, beside each contact's own card
- # and a contact at a time (#import_picker).
- group: Import::Write.default_group,
- )
+ revised = span("import.pare") { cards.map { Import::Vcf.read(it).card } }
+ id = span("import.stage") {
+ Import::Staged.open(
+ original: bytes,
+ revised: joined(revised),
+ # Named here and not at the end, there being no end to name
+ # it at: every Save files its contact under it. Not the
+ # importer's to rename either — a name typed before anything
+ # has been looked at is a name for nothing, and one typed
+ # after would split an import across two groups. What is
+ # editable is the membership, beside each contact's own card
+ # and a contact at a time (#import_picker).
+ group: Import::Write.default_group,
+ )
+ }
# Straight to the first contact rather than to a screen about
# the file: the walk is the point, and a list that has just been
# built from a file nobody has looked at yet is a stop on the
@@ -150,11 +156,13 @@ module ProTacts
# the book already. `current` is the row whose screen is open.
#: (String upload, Array[VCard] originals, Array[VCard] revised, Hash[String, String] saved, current: Integer) -> Admin::ImportSidebar
def walk_sidebar(upload, originals, revised, saved, current:)
- rows = walk_order(revised).map { |index|
- # A literal of several elements is an Array until something
- # says otherwise, and an inline annotation needs its own line.
- dropped = Import::Vcf.read(originals.fetch(index)).dropped
- [import_contact(revised.fetch(index), index), Import::Vcf.losses(dropped).length, index] #: [Contact, Integer, Integer]
+ rows = span("import.sidebar") {
+ walk_order(revised).map { |index|
+ # A literal of several elements is an Array until something
+ # says otherwise, and an inline annotation needs its own line.
+ dropped = Import::Vcf.read(originals.fetch(index)).dropped
+ [import_contact(revised.fetch(index), index), Import::Vcf.losses(dropped).length, index] #: [Contact, Integer, Integer]
+ }
}
Admin::ImportSidebar.new(upload:, rows:, saved:, current:)
end
@@ -170,8 +178,10 @@ module ProTacts
# moves its row the way it would move the contact's.
#: (Array[VCard] revised) -> Array[Integer]
def walk_order(revised)
- contacts = revised.each_with_index.map { |card, index| import_contact(card, index) }
- contacts.each_index.sort_by { |index| [Admin::Format.sort_key(contacts.fetch(index)), index] }
+ span("import.walk_order") {
+ contacts = revised.each_with_index.map { |card, index| import_contact(card, index) }
+ contacts.each_index.sort_by { |index| [Admin::Format.sort_key(contacts.fetch(index)), index] }
+ }
end
# A row opened: the card it arrived as beside the card that is
@@ -225,48 +235,53 @@ module ProTacts
sidebar = walk_sidebar(upload, originals, revised, saved, current: index)
response["Content-Type"] = "text/html; charset=utf-8"
- return Admin::ImportSaved.call(
- contact:,
- login: @login,
- groups: store.groups_of(contact.id),
- all_groups: store.group_choices,
- aside: Admin::ImportOriginal.new(card: original, dropped:),
- sidebar:,
- edit: "/import/#{upload}/#{index}/edit",
- land: "/import/#{upload}/#{index}",
- notice:,
- ) if contact
+ return span("import.render") {
+ Admin::ImportSaved.call(
+ contact:,
+ login: @login,
+ groups: store.groups_of(contact.id),
+ all_groups: store.group_choices,
+ aside: Admin::ImportOriginal.new(card: original, dropped:),
+ sidebar:,
+ edit: "/import/#{upload}/#{index}/edit",
+ land: "/import/#{upload}/#{index}",
+ notice:,
+ )
+ } if contact
arriving = import_contact(card, index)
- matches = Import::Match.candidates(arriving, store.contacts)
+ book = span("import.contacts") { |step| store.contacts.tap { step&.set_data("rows", it.length) } }
+ matches = span("import.match") { Import::Match.candidates(arriving, book) }
target = into.nil? || into.empty? ? nil : store.contact(into)
# The row opened with no choice made is the first choice
# offered, the best match
# (docs/plans/2026-09-23-update-is-the-default.md).
target ||= matches.first if into.nil?
- merge = target && Import::Merge.new(target, card)
- Admin::ContactsEdit.call(
- contact: merge ? merge.contact : arriving,
- login: @login,
- notice:,
- action: "/import/#{upload}/#{index}",
- # The submit says what it does — the import of this one
- # contact, or the update of the one it is folded into —
- # because this is the one editor in the app whose Save can
- # create the record rather than amend it. An update names the
- # contact it writes, the two modes differing nowhere else the
- # eye lands: the fields over a fold are the same shape as over
- # an arriving card, and the toggle above them
- # (Admin::ImportTarget) a pill whose taken side lifts one step.
- # The row beside it wears its check only once this has been
- # pressed (Admin::ImportSidebar).
- save: target ? "Update #{target.name || target.id}" : "Import",
- aside: Admin::ImportOriginal.new(card: original, dropped: merge ? dropped + merge.left : dropped),
- fields: import_picker(group, joined, named, member: target,
- tags: Import::Vcf.categories(original) + Import::Monica.groups(original)),
- lead: import_target(upload, index, matches, target),
- sidebar:,
- )
+ merge = target && span("import.merge") { Import::Merge.new(target, card) }
+ span("import.render") {
+ Admin::ContactsEdit.call(
+ contact: merge ? merge.contact : arriving,
+ login: @login,
+ notice:,
+ action: "/import/#{upload}/#{index}",
+ # The submit says what it does — the import of this one
+ # contact, or the update of the one it is folded into —
+ # because this is the one editor in the app whose Save can
+ # create the record rather than amend it. An update names the
+ # contact it writes, the two modes differing nowhere else the
+ # eye lands: the fields over a fold are the same shape as over
+ # an arriving card, and the toggle above them
+ # (Admin::ImportTarget) a pill whose taken side lifts one step.
+ # The row beside it wears its check only once this has been
+ # pressed (Admin::ImportSidebar).
+ save: target ? "Update #{target.name || target.id}" : "Import",
+ aside: Admin::ImportOriginal.new(card: original, dropped: merge ? dropped + merge.left : dropped),
+ fields: import_picker(group, joined, named, member: target,
+ tags: Import::Vcf.categories(original) + Import::Monica.groups(original)),
+ lead: import_target(upload, index, matches, target),
+ sidebar:,
+ )
+ }
end
# The toggle at the top of an unsaved row's editor, or none when
@@ -488,15 +503,16 @@ module ProTacts
joined: ticked, named: wanted)
end
- written = Import::Write.update(store, target.id, edited, birthday:,
- joins: ticked - was, leaves: was - ticked, named: wanted)
+ written = span("import.write") {
+ Import::Write.update(store, target.id, edited, birthday:, joins: ticked - was, leaves: was - ticked, named: wanted)
+ }
# Staged after the write rather than before, unlike a new
# contact's below: until it lands this row is still the card
# it arrived as, and staging the contact's card in its place
# would make that the card a refused or failed save came back
# to.
revised[index] = edited
- Import::Staged.update(upload, joined(revised))
+ span("import.restage") { Import::Staged.update(upload, joined(revised)) }
return walk_on(r, upload, index, written, saved, revised)
end
@@ -527,8 +543,8 @@ module ProTacts
# renders every row from these bytes, so a row that has come in
# is still a row the list has to be able to name.
revised[index] = edited
- Import::Staged.update(upload, joined(revised))
- written = Import::Write.call(store, edited, joins: ticked, named: wanted, everyone: syncing)
+ span("import.restage") { Import::Staged.update(upload, joined(revised)) }
+ written = span("import.write") { Import::Write.call(store, edited, joins: ticked, named: wanted, everyone: syncing) }
walk_on(r, upload, index, written, saved, revised)
end
@@ -601,7 +617,12 @@ module ProTacts
revised = Import::Staged.read(upload, Import::Staged::REVISED)
return nil if original.nil? || revised.nil?
- [Import::Vcf.cards(original), Import::Vcf.cards(revised)]
+ span("import.staged") { |step|
+ step&.set_data("bytes", original.bytesize + revised.bytesize)
+ cards = [Import::Vcf.cards(original), Import::Vcf.cards(revised)] #: [Array[VCard], Array[VCard]]
+ step&.set_data("cards", cards[0].length)
+ cards
+ }
end
# A staged card read as the contact the editor edits. The id is
diff --git a/test/pro_tacts/admin/test_import_pages.rb b/test/pro_tacts/admin/test_import_pages.rb
index 549a621..742bf6c 100644
--- a/test/pro_tacts/admin/test_import_pages.rb
+++ b/test/pro_tacts/admin/test_import_pages.rb
@@ -12,6 +12,7 @@ require "pro_tacts/web"
# (docs/plans/2026-09-21-import-a-vcf.md).
class AdminImportPagesTest < Minitest::Test
include Rack::Test::Methods
+ include SentryTracing
include ThrowawayContacts
JANE = <<~CARD.gsub("\n", "\r\n")
@@ -975,6 +976,25 @@ class AdminImportPagesTest < Minitest::Test
end
end
+ # Each step of the walk that grows with the file is a span on its
+ # request's transaction, and none carries a card's content (the
+ # import branch's comment). The upload and the Save answer 303s,
+ # traced only under config.ru's statuses.
+ def test_the_walk_times_its_steps_without_the_cards
+ setup_sentry_tracing
+ with_contacts({"jane" => STORED_JANE}) do |_store|
+ id = upload(JANE + PLAIN)
+ save(id, 0, first: "Jane", last: "Booles")
+
+ transactions = sentry_events.grep(Sentry::TransactionEvent)
+ ops = transactions.flat_map { |transaction| transaction.spans.map { it[:op] } }.grep(/\Aimport\./).uniq.sort
+ assert_equal %w[contacts match merge pare render restage sidebar split stage staged walk_order write].map { "import.#{it}" }, ops
+ transactions.each { refute_match(/Booles|Jane/, JSON.generate(it.to_h)) }
+ end
+ ensure
+ teardown_sentry_test
+ end
+
# New contact stays a choice, asked for with an empty `into` where
# the update sides name a contact: the editor over the arriving
# card alone.