Browse Source

feat(perf-issues): Account for multiple file-io spans (#42115)

- Updates the FileIOMainThreadDetector to account for multiple spans
with the same parent. If the total span time exceeds the threshold then
make all of them part of the performance issue
- For now just joining all of the call stacks together as the
fingerprint. This has the benefit of the original fingerprints being
unchanged, but may not be sufficient for the multiple span case
- Closes #42074
William Mak 2 years ago
parent
commit
8040cdd497

+ 477 - 0
fixtures/events/performance_problems/file-io-on-main-thread-with-complicated-structure.json

@@ -0,0 +1,477 @@
+{
+  "event_id": "c119e45a9d724b1891df4651ebf9e6db",
+  "project": 5428559,
+  "release": "io.sentry.samples.android@1.1.0+2",
+  "dist": "2",
+  "platform": "java",
+  "message": "",
+  "datetime": "2022-11-21T11:57:38.806589+00:00",
+  "tags": [
+    [
+      "device",
+      "Android SDK built for x86"
+    ],
+    [
+      "device.family",
+      "Android"
+    ],
+    [
+      "environment",
+      "debug"
+    ],
+    [
+      "isSideLoaded",
+      "true"
+    ],
+    [
+      "level",
+      "info"
+    ],
+    [
+      "os",
+      "Android 10"
+    ],
+    [
+      "os.name",
+      "Android"
+    ],
+    [
+      "os.rooted",
+      "no"
+    ],
+    [
+      "dist",
+      "2"
+    ],
+    [
+      "release",
+      "io.sentry.samples.android@1.1.0+2"
+    ],
+    [
+      "user",
+      "id:0f38dda2-6ef7-4d9a-b7f4-6bcc80b85b2e"
+    ],
+    [
+      "transaction",
+      "MainActivity.add_attachment"
+    ]
+  ],
+  "_metrics": {
+    "bytes.ingested.event": 3725,
+    "bytes.stored.event": 4851
+  },
+  "breadcrumbs": {
+    "values": [
+      {
+        "timestamp": 1669031851.824,
+        "type": "navigation",
+        "category": "ui.lifecycle",
+        "level": "info",
+        "data": {
+          "screen": "MainActivity",
+          "state": "created"
+        }
+      },
+      {
+        "timestamp": 1669031851.942,
+        "type": "navigation",
+        "category": "ui.lifecycle",
+        "level": "info",
+        "data": {
+          "screen": "MainActivity",
+          "state": "started"
+        }
+      },
+      {
+        "timestamp": 1669031851.943,
+        "type": "session",
+        "category": "app.lifecycle",
+        "level": "info",
+        "data": {
+          "state": "start"
+        }
+      },
+      {
+        "timestamp": 1669031851.95,
+        "type": "navigation",
+        "category": "app.lifecycle",
+        "level": "info",
+        "data": {
+          "state": "foreground"
+        }
+      },
+      {
+        "timestamp": 1669031851.953,
+        "type": "navigation",
+        "category": "ui.lifecycle",
+        "level": "info",
+        "data": {
+          "screen": "MainActivity",
+          "state": "resumed"
+        }
+      },
+      {
+        "timestamp": 1669031858.676,
+        "type": "user",
+        "category": "ui.click",
+        "level": "info",
+        "data": {
+          "view.class": "androidx.appcompat.widget.AppCompatButton",
+          "view.id": "add_attachment"
+        }
+      }
+    ]
+  },
+  "breakdowns": {
+    "span_ops": {
+      "total.time": {
+        "value": 94.411134,
+        "unit": "millisecond"
+      }
+    }
+  },
+  "contexts": {
+    "app": {
+      "app_start_time": "2022-11-21T11:57:31.523Z",
+      "app_identifier": "io.sentry.samples.android",
+      "app_name": "Sentry sample",
+      "app_version": "1.1.0",
+      "app_build": "2",
+      "permissions": {
+        "ACCESS_NETWORK_STATE": "granted",
+        "CAMERA": "not_granted",
+        "FOREGROUND_SERVICE": "granted",
+        "INTERNET": "granted",
+        "READ_EXTERNAL_STORAGE": "not_granted",
+        "READ_PHONE_STATE": "not_granted",
+        "WRITE_EXTERNAL_STORAGE": "not_granted"
+      },
+      "type": "app"
+    },
+    "device": {
+      "name": "Android SDK built for x86",
+      "family": "Android",
+      "model": "Android SDK built for x86",
+      "model_id": "QSR1.190920.001",
+      "orientation": "portrait",
+      "manufacturer": "Google",
+      "brand": "google",
+      "screen_density": 2.625,
+      "screen_dpi": 420,
+      "simulator": true,
+      "boot_time": "2022-11-21T10:57:17.182Z",
+      "timezone": "Europe/Vienna",
+      "archs": [
+        "x86"
+      ],
+      "id": "0f38dda2-6ef7-4d9a-b7f4-6bcc80b85b2e",
+      "language": "en",
+      "locale": "en_US",
+      "screen_height_pixels": 1794,
+      "screen_width_pixels": 1080,
+      "type": "device"
+    },
+    "os": {
+      "name": "Android",
+      "version": "10",
+      "build": "QSR1.190920.001",
+      "kernel_version": "4.14.112+",
+      "rooted": false,
+      "type": "os"
+    },
+    "trace": {
+      "trace_id": "b2a33f3f79fe4a7c8de3426725a045cb",
+      "span_id": "b93d2be92cd64fd5",
+      "op": "ui.action.click",
+      "status": "ok",
+      "exclusive_time": 35.177709,
+      "client_sample_rate": 1,
+      "hash": "1ff9a18d6a6b09a8",
+      "type": "trace"
+    }
+  },
+  "culprit": "MainActivity.add_attachment",
+  "environment": "debug",
+  "grouping_config": {
+    "enhancements": "eJybzDRxY3J-bm5-npWRgaGlroGxrpHxxEkT1-Zm5usVp-aVFFXqaWlNZAQAKGsOFg",
+    "id": "newstyle:2019-10-29"
+  },
+  "hashes": [],
+  "ingest_path": [
+    {
+      "version": "22.11.0",
+      "public_key": "XE7QiyuNlja9PZ7I9qJlwQotzecWrUIN91BAO7Q5R38"
+    }
+  ],
+  "key_id": "1336851",
+  "level": "info",
+  "location": "MainActivity.add_attachment",
+  "logger": "",
+  "metadata": {
+    "location": "MainActivity.add_attachment",
+    "title": "MainActivity.add_attachment"
+  },
+  "nodestore_insert": 1669031864.427658,
+  "received": 1669031861.899161,
+  "sdk": {
+    "name": "sentry.java.android.timber",
+    "version": "6.8.0",
+    "packages": [
+      {
+        "name": "maven:io.sentry:sentry",
+        "version": "6.8.0"
+      },
+      {
+        "name": "maven:io.sentry:sentry-android-core",
+        "version": "6.8.0"
+      },
+      {
+        "name": "maven:io.sentry:sentry-android-ndk",
+        "version": "6.8.0"
+      },
+      {
+        "name": "maven:io.sentry:sentry-android-timber",
+        "version": "6.8.0"
+      }
+    ]
+  },
+  "span_grouping_config": {
+    "id": "default:2022-10-27"
+  },
+  "spans": [
+    {
+      "timestamp": 0.002,
+      "start_timestamp": 0,
+      "exclusive_time": 2,
+      "description": "1669031858711_file.txt (4.0 kB)",
+      "op": "file.write",
+      "span_id": "054ba3a374d543eb",
+      "parent_span_id": "b93d2be92cd64fd5",
+      "trace_id": "b2a33f3f79fe4a7c8de3426725a045cb",
+      "status": "ok",
+      "data": {
+        "blocked_main_thread": true,
+        "call_stack": [
+          {
+            "function": "onClick",
+            "in_app": true,
+            "lineno": 2,
+            "module": "io.sentry.samples.android.MainActivity$$ExternalSyntheticLambda6",
+            "native": false
+          },
+          {
+            "filename": "MainActivity.java",
+            "function": "lambda$onCreate$5$io-sentry-samples-android-MainActivity",
+            "in_app": true,
+            "lineno": 93,
+            "module": "io.sentry.samples.android.MainActivity",
+            "native": false
+          }
+        ],
+        "file.path": "/data/user/0/io.sentry.samples.android/files/1669031858711_file.txt",
+        "file.size": 4010
+      },
+      "hash": "8add714f71a52ef2"
+    },
+    {
+      "timestamp": 0.001,
+      "start_timestamp": 0,
+      "exclusive_time": 1,
+      "description": "1669031858721_file.txt (4.0 kB)",
+      "op": "file.write",
+      "span_id": "054ba3a3a4d543ab",
+      "parent_span_id": "b93d2be92cd64fd5",
+      "trace_id": "b2a33f3f79fe4a7c8de3426725a045cb",
+      "status": "ok",
+      "data": {
+        "blocked_main_thread": true,
+        "call_stack": [
+          {
+            "function": "onClick",
+            "in_app": true,
+            "lineno": 2,
+            "module": "io.sentry.samples.android.MainActivity$$ExternalSyntheticLambda6",
+            "native": false
+          }
+        ],
+        "file.path": "/data/user/0/io.sentry.samples.android/files/1669031858721_file.txt",
+        "file.size": 4010
+      },
+      "hash": "8add714f71a5aaaa"
+    },
+    {
+      "timestamp": 0.0035,
+      "start_timestamp": 0.001,
+      "exclusive_time": 2.5,
+      "description": "1669031858721_file.txt (4.0 kB)",
+      "op": "file.write",
+      "span_id": "054ba3a3a4d543cd",
+      "parent_span_id": "b93d2be92cd64fd5",
+      "trace_id": "b2a33f3f79fe4a7c8de3426725a045cb",
+      "status": "ok",
+      "data": {
+        "blocked_main_thread": true,
+        "call_stack": [
+          {
+            "function": "onClick",
+            "in_app": true,
+            "lineno": 2,
+            "module": "io.sentry.samples.android.MainActivity$$ExternalSyntheticLambda6",
+            "native": false
+          }
+        ],
+        "file.path": "/data/user/0/io.sentry.samples.android/files/1669031858721_file.txt",
+        "file.size": 4010
+      },
+      "hash": "8add714f71a5aaaa"
+    },
+    {
+      "timestamp": 0.005,
+      "start_timestamp": 0.0025,
+      "exclusive_time": 1,
+      "description": "1669031858721_file.txt (4.0 kB)",
+      "op": "file.write",
+      "span_id": "054ba3a3a4d543ef",
+      "parent_span_id": "b93d2be92cd64fd5",
+      "trace_id": "b2a33f3f79fe4a7c8de3426725a045cb",
+      "status": "ok",
+      "data": {
+        "blocked_main_thread": true,
+        "call_stack": [
+          {
+            "function": "onClick",
+            "in_app": true,
+            "lineno": 2,
+            "module": "io.sentry.samples.android.MainActivity$$ExternalSyntheticLambda6",
+            "native": false
+          }
+        ],
+        "file.path": "/data/user/0/io.sentry.samples.android/files/1669031858721_file.txt",
+        "file.size": 4010
+      },
+      "hash": "8add714f71a5aaaa"
+    },
+    {
+      "timestamp": 0.008,
+      "start_timestamp": 0.006,
+      "exclusive_time": 1,
+      "description": "1669031858721_file.txt (4.0 kB)",
+      "op": "file.write",
+      "span_id": "054ba3a3a4d54ab1",
+      "parent_span_id": "b93d2be92cd64fd5",
+      "trace_id": "b2a33f3f79fe4a7c8de3426725a045cb",
+      "status": "ok",
+      "data": {
+        "blocked_main_thread": true,
+        "call_stack": [
+          {
+            "function": "onClick",
+            "in_app": true,
+            "lineno": 2,
+            "module": "io.sentry.samples.android.MainActivity$$ExternalSyntheticLambda6",
+            "native": false
+          }
+        ],
+        "file.path": "/data/user/0/io.sentry.samples.android/files/1669031858721_file.txt",
+        "file.size": 4010
+      },
+      "hash": "8add714f71a5aaaa"
+    },
+    {
+      "timestamp": 0.0075,
+      "start_timestamp": 0.0065,
+      "exclusive_time": 1,
+      "description": "1669031858721_file.txt (4.0 kB)",
+      "op": "file.write",
+      "span_id": "054ba3a3a4d54ab2",
+      "parent_span_id": "b93d2be92cd64fd5",
+      "trace_id": "b2a33f3f79fe4a7c8de3426725a045cb",
+      "status": "ok",
+      "data": {
+        "blocked_main_thread": true,
+        "call_stack": [
+          {
+            "function": "onClick",
+            "in_app": true,
+            "lineno": 2,
+            "module": "io.sentry.samples.android.MainActivity$$ExternalSyntheticLambda6",
+            "native": false
+          }
+        ],
+        "file.path": "/data/user/0/io.sentry.samples.android/files/1669031858721_file.txt",
+        "file.size": 4010
+      },
+      "hash": "8add714f71a5aaaa"
+    },
+    {
+      "timestamp": 0.009,
+      "start_timestamp": 0.007,
+      "exclusive_time": 1,
+      "description": "1669031858721_file.txt (4.0 kB)",
+      "op": "file.write",
+      "span_id": "054ba3a3a4d54ab3",
+      "parent_span_id": "b93d2be92cd64fd5",
+      "trace_id": "b2a33f3f79fe4a7c8de3426725a045cb",
+      "status": "ok",
+      "data": {
+        "blocked_main_thread": true,
+        "call_stack": [
+          {
+            "function": "onClick",
+            "in_app": true,
+            "lineno": 2,
+            "module": "io.sentry.samples.android.MainActivity$$ExternalSyntheticLambda6",
+            "native": false
+          }
+        ],
+        "file.path": "/data/user/0/io.sentry.samples.android/files/1669031858721_file.txt",
+        "file.size": 4010
+      },
+      "hash": "8add714f71a5aaaa"
+    },
+    {
+      "timestamp": 0.018,
+      "start_timestamp": 0.01,
+      "exclusive_time": 1,
+      "description": "1669031858721_file.txt (4.0 kB)",
+      "op": "file.write",
+      "span_id": "054ba3a3a4d54ab4",
+      "parent_span_id": "b93d2be92cd64fd5",
+      "trace_id": "b2a33f3f79fe4a7c8de3426725a045cb",
+      "status": "ok",
+      "data": {
+        "blocked_main_thread": true,
+        "call_stack": [
+          {
+            "function": "onClick",
+            "in_app": true,
+            "lineno": 2,
+            "module": "io.sentry.samples.android.MainActivity$$ExternalSyntheticLambda6",
+            "native": false
+          }
+        ],
+        "file.path": "/data/user/0/io.sentry.samples.android/files/1669031858721_file.txt",
+        "file.size": 4010
+      },
+      "hash": "8add714f71a5aaaa"
+    }
+  ],
+  "start_timestamp": 1669031858.677,
+  "timestamp": 1669031858.806589,
+  "title": "MainActivity.add_attachment",
+  "transaction": "MainActivity.add_attachment",
+  "transaction_info": {
+    "source": "component"
+  },
+  "type": "transaction",
+  "user": {
+    "id": "0f38dda2-6ef7-4d9a-b7f4-6bcc80b85b2e",
+    "ip_address": "86.56.213.87",
+    "geo": {
+      "country_code": "AT",
+      "city": "Enns",
+      "region": "Austria"
+    }
+  },
+  "version": "7"
+}

+ 329 - 0
fixtures/events/performance_problems/file-io-on-main-thread-with-parallel-spans.json

@@ -0,0 +1,329 @@
+{
+  "event_id": "c119e45a9d724b1891df4651ebf9e6db",
+  "project": 5428559,
+  "release": "io.sentry.samples.android@1.1.0+2",
+  "dist": "2",
+  "platform": "java",
+  "message": "",
+  "datetime": "2022-11-21T11:57:38.806589+00:00",
+  "tags": [
+    [
+      "device",
+      "Android SDK built for x86"
+    ],
+    [
+      "device.family",
+      "Android"
+    ],
+    [
+      "environment",
+      "debug"
+    ],
+    [
+      "isSideLoaded",
+      "true"
+    ],
+    [
+      "level",
+      "info"
+    ],
+    [
+      "os",
+      "Android 10"
+    ],
+    [
+      "os.name",
+      "Android"
+    ],
+    [
+      "os.rooted",
+      "no"
+    ],
+    [
+      "dist",
+      "2"
+    ],
+    [
+      "release",
+      "io.sentry.samples.android@1.1.0+2"
+    ],
+    [
+      "user",
+      "id:0f38dda2-6ef7-4d9a-b7f4-6bcc80b85b2e"
+    ],
+    [
+      "transaction",
+      "MainActivity.add_attachment"
+    ]
+  ],
+  "_metrics": {
+    "bytes.ingested.event": 3725,
+    "bytes.stored.event": 4851
+  },
+  "breadcrumbs": {
+    "values": [
+      {
+        "timestamp": 1669031851.824,
+        "type": "navigation",
+        "category": "ui.lifecycle",
+        "level": "info",
+        "data": {
+          "screen": "MainActivity",
+          "state": "created"
+        }
+      },
+      {
+        "timestamp": 1669031851.942,
+        "type": "navigation",
+        "category": "ui.lifecycle",
+        "level": "info",
+        "data": {
+          "screen": "MainActivity",
+          "state": "started"
+        }
+      },
+      {
+        "timestamp": 1669031851.943,
+        "type": "session",
+        "category": "app.lifecycle",
+        "level": "info",
+        "data": {
+          "state": "start"
+        }
+      },
+      {
+        "timestamp": 1669031851.95,
+        "type": "navigation",
+        "category": "app.lifecycle",
+        "level": "info",
+        "data": {
+          "state": "foreground"
+        }
+      },
+      {
+        "timestamp": 1669031851.953,
+        "type": "navigation",
+        "category": "ui.lifecycle",
+        "level": "info",
+        "data": {
+          "screen": "MainActivity",
+          "state": "resumed"
+        }
+      },
+      {
+        "timestamp": 1669031858.676,
+        "type": "user",
+        "category": "ui.click",
+        "level": "info",
+        "data": {
+          "view.class": "androidx.appcompat.widget.AppCompatButton",
+          "view.id": "add_attachment"
+        }
+      }
+    ]
+  },
+  "breakdowns": {
+    "span_ops": {
+      "total.time": {
+        "value": 94.411134,
+        "unit": "millisecond"
+      }
+    }
+  },
+  "contexts": {
+    "app": {
+      "app_start_time": "2022-11-21T11:57:31.523Z",
+      "app_identifier": "io.sentry.samples.android",
+      "app_name": "Sentry sample",
+      "app_version": "1.1.0",
+      "app_build": "2",
+      "permissions": {
+        "ACCESS_NETWORK_STATE": "granted",
+        "CAMERA": "not_granted",
+        "FOREGROUND_SERVICE": "granted",
+        "INTERNET": "granted",
+        "READ_EXTERNAL_STORAGE": "not_granted",
+        "READ_PHONE_STATE": "not_granted",
+        "WRITE_EXTERNAL_STORAGE": "not_granted"
+      },
+      "type": "app"
+    },
+    "device": {
+      "name": "Android SDK built for x86",
+      "family": "Android",
+      "model": "Android SDK built for x86",
+      "model_id": "QSR1.190920.001",
+      "orientation": "portrait",
+      "manufacturer": "Google",
+      "brand": "google",
+      "screen_density": 2.625,
+      "screen_dpi": 420,
+      "simulator": true,
+      "boot_time": "2022-11-21T10:57:17.182Z",
+      "timezone": "Europe/Vienna",
+      "archs": [
+        "x86"
+      ],
+      "id": "0f38dda2-6ef7-4d9a-b7f4-6bcc80b85b2e",
+      "language": "en",
+      "locale": "en_US",
+      "screen_height_pixels": 1794,
+      "screen_width_pixels": 1080,
+      "type": "device"
+    },
+    "os": {
+      "name": "Android",
+      "version": "10",
+      "build": "QSR1.190920.001",
+      "kernel_version": "4.14.112+",
+      "rooted": false,
+      "type": "os"
+    },
+    "trace": {
+      "trace_id": "b2a33f3f79fe4a7c8de3426725a045cb",
+      "span_id": "b93d2be92cd64fd5",
+      "op": "ui.action.click",
+      "status": "ok",
+      "exclusive_time": 35.177709,
+      "client_sample_rate": 1,
+      "hash": "1ff9a18d6a6b09a8",
+      "type": "trace"
+    }
+  },
+  "culprit": "MainActivity.add_attachment",
+  "environment": "debug",
+  "grouping_config": {
+    "enhancements": "eJybzDRxY3J-bm5-npWRgaGlroGxrpHxxEkT1-Zm5usVp-aVFFXqaWlNZAQAKGsOFg",
+    "id": "newstyle:2019-10-29"
+  },
+  "hashes": [],
+  "ingest_path": [
+    {
+      "version": "22.11.0",
+      "public_key": "XE7QiyuNlja9PZ7I9qJlwQotzecWrUIN91BAO7Q5R38"
+    }
+  ],
+  "key_id": "1336851",
+  "level": "info",
+  "location": "MainActivity.add_attachment",
+  "logger": "",
+  "metadata": {
+    "location": "MainActivity.add_attachment",
+    "title": "MainActivity.add_attachment"
+  },
+  "nodestore_insert": 1669031864.427658,
+  "received": 1669031861.899161,
+  "sdk": {
+    "name": "sentry.java.android.timber",
+    "version": "6.8.0",
+    "packages": [
+      {
+        "name": "maven:io.sentry:sentry",
+        "version": "6.8.0"
+      },
+      {
+        "name": "maven:io.sentry:sentry-android-core",
+        "version": "6.8.0"
+      },
+      {
+        "name": "maven:io.sentry:sentry-android-ndk",
+        "version": "6.8.0"
+      },
+      {
+        "name": "maven:io.sentry:sentry-android-timber",
+        "version": "6.8.0"
+      }
+    ]
+  },
+  "span_grouping_config": {
+    "id": "default:2022-10-27"
+  },
+  "spans": [
+    {
+      "timestamp": 1669031858.008,
+      "start_timestamp": 1669031858.0,
+      "exclusive_time": 8,
+      "description": "1669031858711_file.txt (4.0 kB)",
+      "op": "file.write",
+      "span_id": "054ba3a374d543eb",
+      "parent_span_id": "b93d2be92cd64fd5",
+      "trace_id": "b2a33f3f79fe4a7c8de3426725a045cb",
+      "status": "ok",
+      "data": {
+        "blocked_main_thread": true,
+        "call_stack": [
+          {
+            "function": "onClick",
+            "in_app": true,
+            "lineno": 2,
+            "module": "io.sentry.samples.android.MainActivity$$ExternalSyntheticLambda6",
+            "native": false
+          },
+          {
+            "filename": "MainActivity.java",
+            "function": "lambda$onCreate$5$io-sentry-samples-android-MainActivity",
+            "in_app": true,
+            "lineno": 93,
+            "module": "io.sentry.samples.android.MainActivity",
+            "native": false
+          }
+        ],
+        "file.path": "/data/user/0/io.sentry.samples.android/files/1669031858711_file.txt",
+        "file.size": 4010
+      },
+      "hash": "8add714f71a52ef2"
+    },
+    {
+      "timestamp": 1669031858.016,
+      "start_timestamp": 1669031858.004,
+      "exclusive_time": 12,
+      "description": "1669031858721_file.txt (4.0 kB)",
+      "op": "file.write",
+      "span_id": "054ba3a3a4d543ab",
+      "parent_span_id": "b93d2be92cd64fd5",
+      "trace_id": "b2a33f3f79fe4a7c8de3426725a045cb",
+      "status": "ok",
+      "data": {
+        "blocked_main_thread": true,
+        "call_stack": [
+          {
+            "function": "onClick",
+            "in_app": true,
+            "lineno": 2,
+            "module": "io.sentry.samples.android.MainActivity$$ExternalSyntheticLambda6",
+            "native": false
+          },
+          {
+            "filename": "MainActivity.java",
+            "function": "lambda$onCreate$5$io-sentry-samples-android-MainActivity",
+            "in_app": true,
+            "lineno": 93,
+            "module": "io.sentry.samples.android.MainActivity",
+            "native": false
+          }
+        ],
+        "file.path": "/data/user/0/io.sentry.samples.android/files/1669031858721_file.txt",
+        "file.size": 4010
+      },
+      "hash": "8add714f71a5aaaa"
+    }
+  ],
+  "start_timestamp": 1669031858.677,
+  "timestamp": 1669031858.806589,
+  "title": "MainActivity.add_attachment",
+  "transaction": "MainActivity.add_attachment",
+  "transaction_info": {
+    "source": "component"
+  },
+  "type": "transaction",
+  "user": {
+    "id": "0f38dda2-6ef7-4d9a-b7f4-6bcc80b85b2e",
+    "ip_address": "86.56.213.87",
+    "geo": {
+      "country_code": "AT",
+      "city": "Enns",
+      "region": "Austria"
+    }
+  },
+  "version": "7"
+}

+ 51 - 14
src/sentry/utils/performance_issues/performance_detection.py

@@ -6,6 +6,7 @@ import os
 import random
 import re
 from abc import ABC, abstractmethod
+from collections import defaultdict
 from dataclasses import dataclass
 from datetime import timedelta
 from enum import Enum
@@ -501,6 +502,29 @@ def contains_complete_query(span: Span, is_source: Optional[bool] = False) -> bo
         return query and not query.endswith("...")
 
 
+def total_span_time(span_list: List[Dict[str, Any]]) -> float:
+    """Return the total non-overlapping span time in milliseconds for all the spans in the list"""
+    # Sort the spans so that when iterating the next span in the list is either within the current, or afterwards
+    sorted_span_list = sorted(span_list, key=lambda span: span["start_timestamp"])
+    total_duration = 0
+    first_item = sorted_span_list[0]
+    current_min = first_item["start_timestamp"]
+    current_max = first_item["timestamp"]
+    for span in sorted_span_list[1:]:
+        # If the start is contained within the current, check if the max extends the current duration
+        if current_min <= span["start_timestamp"] <= current_max:
+            current_max = max(span["timestamp"], current_max)
+        # If not within current min&max then there's a gap between spans, so add to total_duration and start a new
+        # min/max
+        else:
+            total_duration += current_max - current_min
+            current_min = span["start_timestamp"]
+            current_max = span["timestamp"]
+    # Add the remaining duration
+    total_duration += current_max - current_min
+    return total_duration * 1000
+
+
 class PerformanceDetector(ABC):
     """
     Classes of this type have their visit functions called as the event is walked once and will store a performance issue if one is detected.
@@ -1327,6 +1351,7 @@ class FileIOMainThreadDetector(PerformanceDetector):
         self.most_recent_hash = {}
         self.stored_problems = {}
         self.mapper = None
+        self.parent_to_blocked_span = defaultdict(list)
         self._prepare_deobfuscation()
 
     def _prepare_deobfuscation(self):
@@ -1374,30 +1399,42 @@ class FileIOMainThreadDetector(PerformanceDetector):
 
     def visit_span(self, span: Span):
         if self._is_file_io_on_main_thread(span):
-            settings_for_span = self.settings_for_span(span)
+            parent_span_id = span.get("parent_span_id")
+            self.parent_to_blocked_span[parent_span_id].append(span)
+
+    def on_complete(self):
+        for parent_span_id, span_list in self.parent_to_blocked_span.items():
+            span_list = [
+                span for span in span_list if "start_timestamp" in span and "timestamp" in span
+            ]
+            total_duration = total_span_time(span_list)
+            settings_for_span = self.settings_for_span(span_list[0])
             if not settings_for_span:
                 return
 
-            op, span_id, op_prefix, span_duration, settings = settings_for_span
-            if span_duration.total_seconds() * 1000 > settings["duration_threshold"]:
-                fingerprint = self._fingerprint(span)
+            _, _, _, _, settings = settings_for_span
+            if total_duration >= settings["duration_threshold"]:
+                fingerprint = self._fingerprint(span_list)
                 self.stored_problems[fingerprint] = PerformanceProblem(
                     fingerprint=fingerprint,
-                    op=span.get("op"),
-                    desc=span.get("description", ""),
-                    parent_span_ids=[span.get("parent_span_id")],
+                    op=span_list[0].get("op"),
+                    desc=span_list[0].get("description", ""),
+                    parent_span_ids=[parent_span_id],
                     type=GroupType.PERFORMANCE_FILE_IO_MAIN_THREAD,
                     cause_span_ids=[],
-                    offender_span_ids=[span.get("span_id", None)],
+                    offender_span_ids=[span["span_id"] for span in span_list if "span_id" in span],
                 )
 
-    def _fingerprint(self, span) -> str:
+    def _fingerprint(self, span_list) -> str:
         call_stack_strings = []
-        for item in span.get("data", {}).get("call_stack", []):
-            module = self._deobfuscate_module(item.get("module", ""))
-            function = self._deobfuscate_function(item)
-            call_stack_strings.append(f"{module}.{function}")
-        call_stack = ".".join(call_stack_strings).encode("utf8")
+        overall_stack = []
+        for span in span_list:
+            for item in span.get("data", {}).get("call_stack", []):
+                module = self._deobfuscate_module(item.get("module", ""))
+                function = self._deobfuscate_function(item)
+                call_stack_strings.append(f"{module}.{function}")
+            overall_stack.append(".".join(call_stack_strings))
+        call_stack = "-".join(overall_stack).encode("utf8")
         hashed_stack = hashlib.sha1(call_stack).hexdigest()
         return f"1-{GroupType.PERFORMANCE_FILE_IO_MAIN_THREAD}-{hashed_stack}"
 

+ 33 - 0
tests/sentry/utils/performance_issues/test_file_io_on_main_thread_detector.py

@@ -66,3 +66,36 @@ class NPlusOneAPICallsDetectorTest(TestCase):
             problem.fingerprint == f"1-{GroupType.PERFORMANCE_FILE_IO_MAIN_THREAD}-{hashed_stack}"
         )
         assert problem.title == "File IO on Main Thread"
+
+    def test_parallel_spans(self):
+        event = EVENTS["file-io-on-main-thread-with-parallel-spans"]
+
+        detector = FileIOMainThreadDetector(self.settings, event)
+        run_detector_on_data(detector, event)
+        problem = list(detector.stored_problems.values())[0]
+        assert problem.offender_span_ids == ["054ba3a374d543eb", "054ba3a3a4d543ab"]
+
+    def test_parallel_spans_not_detected_when_total_too_short(self):
+        event = EVENTS["file-io-on-main-thread-with-parallel-spans"]
+        event["spans"][1]["timestamp"] = 1669031858.015
+
+        detector = FileIOMainThreadDetector(self.settings, event)
+        run_detector_on_data(detector, event)
+        assert len(detector.stored_problems) == 0
+
+    def test_complicated_structure(self):
+        event = EVENTS["file-io-on-main-thread-with-complicated-structure"]
+
+        detector = FileIOMainThreadDetector(self.settings, event)
+        run_detector_on_data(detector, event)
+        problem = list(detector.stored_problems.values())[0]
+        assert problem.offender_span_ids == [
+            "054ba3a374d543eb",
+            "054ba3a3a4d543ab",
+            "054ba3a3a4d543cd",
+            "054ba3a3a4d543ef",
+            "054ba3a3a4d54ab1",
+            "054ba3a3a4d54ab2",
+            "054ba3a3a4d54ab3",
+            "054ba3a3a4d54ab4",
+        ]

+ 119 - 0
tests/sentry/utils/performance_issues/test_performance_detection.py

@@ -24,6 +24,7 @@ from sentry.utils.performance_issues.performance_detection import (
     _detect_performance_problems,
     detect_performance_problems,
     prepare_problem_for_grouping,
+    total_span_time,
 )
 from sentry.utils.performance_issues.performance_span_issue import PerformanceSpanProblem
 
@@ -1112,3 +1113,121 @@ class EventPerformanceProblemTest(TestCase):
         assert [r.problem if r else None for r in result] == [
             problem for _, problem in all_event_problems
         ] + [None]
+
+
+@pytest.mark.parametrize(
+    "spans, duration",
+    [
+        pytest.param(
+            [
+                {
+                    "start_timestamp": 0,
+                    "timestamp": 0.011,
+                }
+            ],
+            11,
+        ),
+        pytest.param(
+            [
+                {
+                    "start_timestamp": 0,
+                    "timestamp": 0.011,
+                },
+                {
+                    "start_timestamp": 0,
+                    "timestamp": 0.011,
+                },
+            ],
+            11,
+            id="parallel spans",
+        ),
+        pytest.param(
+            [
+                {
+                    "start_timestamp": 0,
+                    "timestamp": 0.011,
+                },
+                {
+                    "start_timestamp": 1.0,
+                    "timestamp": 1.011,
+                },
+            ],
+            22,
+            id="separate spans",
+        ),
+        pytest.param(
+            [
+                {
+                    "start_timestamp": 0,
+                    "timestamp": 0.011,
+                },
+                {
+                    "start_timestamp": 0.005,
+                    "timestamp": 0.016,
+                },
+            ],
+            16,
+            id="overlapping spans",
+        ),
+        pytest.param(
+            [
+                {
+                    "start_timestamp": 0,
+                    "timestamp": 0.011,
+                },
+                {
+                    "start_timestamp": 0.005,
+                    "timestamp": 0.016,
+                },
+                {
+                    "start_timestamp": 0.015,
+                    "timestamp": 0.032,
+                },
+            ],
+            32,
+            id="multiple overlapping spans",
+        ),
+        pytest.param(
+            [
+                {
+                    "start_timestamp": 0,
+                    "timestamp": 0.011,
+                },
+                {
+                    "start_timestamp": 0.011,
+                    "timestamp": 0.022,
+                },
+                {
+                    "start_timestamp": 0.022,
+                    "timestamp": 0.033,
+                },
+            ],
+            33,
+            id="multiple overlapping touching spans",
+        ),
+        pytest.param(
+            [
+                {
+                    "start_timestamp": 0,
+                    "timestamp": 0.011,
+                },
+                {
+                    "start_timestamp": 0.005,
+                    "timestamp": 0.022,
+                },
+                {
+                    "start_timestamp": 0.033,
+                    "timestamp": 0.045,
+                },
+                {
+                    "start_timestamp": 0.045,
+                    "timestamp": 0.055,
+                },
+            ],
+            44,
+            id="multiple overlapping spans with gaps",
+        ),
+    ],
+)
+def test_total_span_time(spans, duration):
+    assert total_span_time(spans) == pytest.approx(duration, 0.01)