Skip to content

Commit 5db9f93

Browse files
authored
fix: Allow transaction tags to be accessed and modified in beforeSend (#6910)
1 parent 70ac6c6 commit 5db9f93

File tree

5 files changed

+439
-1
lines changed

5 files changed

+439
-1
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44

55
### Fixes
66

7-
- fix: Ensure SentrySDK.close resets everything on the main thread (#6907)
7+
- Ensure SentrySDK.close resets everything on the main thread (#6907)
8+
- Allow transaction tags to be accessed and modified in `beforeSend` (#6910)
89

910
## 9.0.0-rc.1
1011

Sources/Sentry/Public/SentryEvent.h

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,14 @@ NS_SWIFT_NAME(Event)
101101

102102
/**
103103
* Arbitrary key:value (string:string ) data that will be shown with the event.
104+
*
105+
* @note For @c SentryTransaction instances accessed in @c beforeSend callbacks, this property
106+
* returns a merged dictionary of both event tags and tracer tags (with tracer tags taking
107+
* precedence). Modifications to this dictionary persist when using Swift's dictionary subscript
108+
* assignment (e.g., @c transaction.tags?["key"] = "value" ), which automatically calls the setter.
109+
*
110+
* In Objective-C, you must explicitly call the setter after modifying the dictionary to persist
111+
* changes (e.g., @c transaction.tags = modifiedDict ).
104112
*/
105113
@property (nonatomic, strong) NSDictionary<NSString *, NSString *> *_Nullable tags;
106114

Sources/Sentry/SentryTransaction.m

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,65 @@ - (instancetype)initWithTrace:(SentryTracer *)trace children:(NSArray<id<SentryS
2222
return self;
2323
}
2424

25+
- (nullable NSDictionary<NSString *, NSString *> *)tags
26+
{
27+
if (self.trace == nil) {
28+
// Fallback to superclass if trace is nil (shouldn't happen for valid transactions)
29+
return [super tags];
30+
}
31+
32+
// Merge event tags and tracer tags (tracer tags take precedence)
33+
NSDictionary<NSString *, NSString *> *eventTags = [super tags] ?: @{};
34+
NSDictionary<NSString *, NSString *> *tracerTags = self.trace.tags ?: @{};
35+
36+
// Merge both, with tracer tags taking precedence
37+
// Note: We return a mutable dictionary copy (though declared as NSDictionary *).
38+
//
39+
// Swift behavior:
40+
// When Swift code does: transaction.tags?["key"] = "value"
41+
// Swift expands this to: get tags, modify copy, call setter with modified copy.
42+
// So the setter IS called automatically, which is why modifications persist.
43+
//
44+
// Objective-C behavior:
45+
// In Objective-C, modifying the returned dictionary directly does NOT persist:
46+
// NSMutableDictionary *tags = transaction.tags;
47+
// tags[@"key"] = @"value"; // Modifies local copy only!
48+
// To persist changes in Objective-C, you must explicitly call the setter:
49+
// transaction.tags = tags; // Now changes persist
50+
NSMutableDictionary<NSString *, NSString *> *merged =
51+
[NSMutableDictionary dictionaryWithDictionary:eventTags];
52+
[merged addEntriesFromDictionary:tracerTags];
53+
return merged;
54+
}
55+
56+
- (void)setTags:(NSDictionary<NSString *, NSString *> *_Nullable)tags
57+
{
58+
if (self.trace == nil) {
59+
// Fallback to superclass if trace is nil (shouldn't happen for valid transactions)
60+
[super setTags:tags];
61+
return;
62+
}
63+
64+
// Remove all existing tags from the tracer
65+
NSDictionary<NSString *, NSString *> *currentTracerTags = self.trace.tags;
66+
for (NSString *key in currentTracerTags.allKeys) {
67+
[self.trace removeTagForKey:key];
68+
}
69+
70+
// Clear event tags on the event
71+
[super setTags:nil];
72+
73+
// Set all new tags on the tracer (transaction tags belong on tracer)
74+
if (tags != nil) {
75+
for (NSString *key in tags.allKeys) {
76+
NSString *value = tags[key];
77+
if (value != nil) {
78+
[self.trace setTagValue:value forKey:key];
79+
}
80+
}
81+
}
82+
}
83+
2584
- (NSDictionary<NSString *, id> *)serialize
2685
{
2786
NSMutableDictionary<NSString *, id> *serializedData =

Tests/SentryTests/SentryClientTests.swift

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1312,6 +1312,186 @@ class SentryClientTests: XCTestCase {
13121312
XCTAssertEqual([], actual.threads)
13131313
}
13141314

1315+
func testBeforeSendTransaction_ReadTags() throws {
1316+
// Arrange
1317+
let transaction = fixture.transaction
1318+
transaction.trace.setTag(value: "tracer-value", key: "tracer-key")
1319+
let scope = Scope()
1320+
scope.setTag(value: "event-value", key: "event-key")
1321+
1322+
// Act
1323+
fixture.getSut(configureOptions: { options in
1324+
options.beforeSend = { event in
1325+
guard let transaction = event as? Transaction else {
1326+
return event
1327+
}
1328+
// Read tags - should return merged tags from tracer and event
1329+
guard let tags = transaction.tags else {
1330+
XCTFail("Tags should not be nil")
1331+
return transaction
1332+
}
1333+
XCTAssertEqual(tags["tracer-key"], "tracer-value")
1334+
XCTAssertEqual(tags["event-key"], "event-value")
1335+
return transaction
1336+
}
1337+
}).capture(event: transaction, scope: scope)
1338+
1339+
// Assert
1340+
let actual = try lastSentEvent()
1341+
let serialized = actual.serialize()
1342+
let tags = try XCTUnwrap(serialized["tags"] as? [String: String])
1343+
XCTAssertEqual(tags["tracer-key"], "tracer-value")
1344+
XCTAssertEqual(tags["event-key"], "event-value")
1345+
}
1346+
1347+
func testBeforeSendTransaction_AddTags() throws {
1348+
// Arrange
1349+
let transaction = fixture.transaction
1350+
transaction.trace.setTag(value: "existing-value", key: "existing-key")
1351+
1352+
// Act
1353+
fixture.getSut(configureOptions: { options in
1354+
options.beforeSend = { event in
1355+
guard let transaction = event as? Transaction else {
1356+
return event
1357+
}
1358+
// Add new tag
1359+
// Note: `transaction.tags?["key"] = "value"` is syntactic sugar that:
1360+
// 1. Gets the dictionary (calls getter)
1361+
// 2. Creates a mutable copy
1362+
// 3. Modifies the copy
1363+
// 4. Calls setter with the modified copy: `transaction.tags = modifiedDict`
1364+
// This is why modifications persist - the setter is called!
1365+
transaction.tags?["new-key"] = "new-value"
1366+
return transaction
1367+
}
1368+
}).capture(event: transaction)
1369+
1370+
// Assert
1371+
let actual = try lastSentEvent()
1372+
let serialized = actual.serialize()
1373+
let tags = try XCTUnwrap(serialized["tags"] as? [String: String])
1374+
XCTAssertEqual(tags["existing-key"], "existing-value")
1375+
XCTAssertEqual(tags["new-key"], "new-value")
1376+
}
1377+
1378+
func testBeforeSendTransaction_ModifyTags() throws {
1379+
// Arrange
1380+
let transaction = fixture.transaction
1381+
transaction.trace.setTag(value: "original-value", key: "tracer-key")
1382+
let scope = Scope()
1383+
scope.setTag(value: "original-event-value", key: "event-key")
1384+
1385+
// Act
1386+
fixture.getSut(configureOptions: { options in
1387+
options.beforeSend = { event in
1388+
guard let transaction = event as? Transaction else {
1389+
return event
1390+
}
1391+
// Modify both tracer tag and event tag
1392+
transaction.tags?["tracer-key"] = "modified-tracer-value"
1393+
transaction.tags?["event-key"] = "modified-event-value"
1394+
return transaction
1395+
}
1396+
}).capture(event: transaction, scope: scope)
1397+
1398+
// Assert
1399+
let actual = try lastSentEvent()
1400+
let serialized = actual.serialize()
1401+
let tags = try XCTUnwrap(serialized["tags"] as? [String: String])
1402+
XCTAssertEqual(tags["tracer-key"], "modified-tracer-value")
1403+
XCTAssertEqual(tags["event-key"], "modified-event-value")
1404+
}
1405+
1406+
func testBeforeSendTransaction_RemoveTags() throws {
1407+
// Arrange
1408+
let transaction = fixture.transaction
1409+
transaction.trace.setTag(value: "tracer-value", key: "tracer-key")
1410+
let scope = Scope()
1411+
scope.setTag(value: "event-value", key: "event-key")
1412+
1413+
// Act
1414+
fixture.getSut(configureOptions: { options in
1415+
options.beforeSend = { event in
1416+
guard let transaction = event as? Transaction else {
1417+
return event
1418+
}
1419+
// Remove both tracer tag and event tag
1420+
transaction.tags?["tracer-key"] = nil
1421+
transaction.tags?["event-key"] = nil
1422+
return transaction
1423+
}
1424+
}).capture(event: transaction, scope: scope)
1425+
1426+
// Assert
1427+
let actual = try lastSentEvent()
1428+
let serialized = actual.serialize()
1429+
let tags = try XCTUnwrap(serialized["tags"] as? [String: String])
1430+
XCTAssertNil(tags["tracer-key"])
1431+
XCTAssertNil(tags["event-key"])
1432+
}
1433+
1434+
func testBeforeSendTransaction_ReplaceTags() throws {
1435+
// Arrange
1436+
let transaction = fixture.transaction
1437+
transaction.trace.setTag(value: "tracer-value", key: "tracer-key")
1438+
let scope = Scope()
1439+
scope.setTag(value: "event-value", key: "event-key")
1440+
1441+
// Act
1442+
fixture.getSut(configureOptions: { options in
1443+
options.beforeSend = { event in
1444+
guard let transaction = event as? Transaction else {
1445+
return event
1446+
}
1447+
// Replace all tags
1448+
transaction.tags = ["replaced-key": "replaced-value"]
1449+
return transaction
1450+
}
1451+
}).capture(event: transaction, scope: scope)
1452+
1453+
// Assert
1454+
let actual = try lastSentEvent()
1455+
let serialized = actual.serialize()
1456+
let tags = try XCTUnwrap(serialized["tags"] as? [String: String])
1457+
XCTAssertEqual(tags.count, 1)
1458+
XCTAssertEqual(tags["replaced-key"], "replaced-value")
1459+
XCTAssertNil(tags["tracer-key"])
1460+
XCTAssertNil(tags["event-key"])
1461+
}
1462+
1463+
func testBeforeSendTransaction_DictionarySubscriptAssignmentCallsSetter() throws {
1464+
// Arrange
1465+
// This test verifies that `transaction.tags?["key"] = "value"` actually calls the setter
1466+
// In Swift, dictionary subscript assignment with optional chaining expands to:
1467+
// if var tags = transaction.tags {
1468+
// tags["key"] = "value"
1469+
// transaction.tags = tags // <-- setter is called here!
1470+
// }
1471+
let transaction = fixture.transaction
1472+
transaction.trace.setTag(value: "original", key: "key")
1473+
1474+
// Act - modify via subscript assignment
1475+
fixture.getSut(configureOptions: { options in
1476+
options.beforeSend = { event in
1477+
guard let transaction = event as? Transaction else {
1478+
return event
1479+
}
1480+
// This subscript assignment calls the setter under the hood
1481+
transaction.tags?["key"] = "modified"
1482+
transaction.tags?["new-key"] = "new-value"
1483+
return transaction
1484+
}
1485+
}).capture(event: transaction)
1486+
1487+
// Assert - verify the setter was called and changes persisted
1488+
let actual = try lastSentEvent()
1489+
let serialized = actual.serialize()
1490+
let tags = try XCTUnwrap(serialized["tags"] as? [String: String])
1491+
XCTAssertEqual(tags["key"], "modified", "Subscript assignment should call setter and persist changes")
1492+
XCTAssertEqual(tags["new-key"], "new-value", "New tags added via subscript should persist")
1493+
}
1494+
13151495
func testBeforeSendSpanDitchOneSpan_OtherChangedSpanSent() throws {
13161496
let spanOne = getSpan(operation: "operation.one", tracer: fixture.trace)
13171497
let spanTwo = getSpan(operation: "operation.two", tracer: fixture.trace)

0 commit comments

Comments
 (0)