test_profiles.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607
  1. from datetime import datetime, timedelta, timezone
  2. import pytest
  3. from snuba_sdk.aliased_expression import AliasedExpression
  4. from snuba_sdk.column import Column
  5. from snuba_sdk.conditions import Condition, Op, Or
  6. from snuba_sdk.function import Function
  7. from snuba_sdk.orderby import Direction, OrderBy
  8. from sentry.exceptions import InvalidSearchQuery
  9. from sentry.search.events.builder import ProfilesQueryBuilder, ProfilesTimeseriesQueryBuilder
  10. from sentry.search.events.datasets.profiles import COLUMNS as PROFILE_COLUMNS
  11. from sentry.search.events.datasets.profiles import ProfilesDatasetConfig
  12. from sentry.snuba.dataset import Dataset
  13. from sentry.testutils.factories import Factories
  14. from sentry.testutils.pytest.fixtures import django_db_all
  15. # pin a timestamp for now so tests results dont change
  16. now = datetime(2022, 10, 31, 0, 0, tzinfo=timezone.utc)
  17. today = now.replace(hour=0, minute=0, second=0, microsecond=0)
  18. @pytest.fixture
  19. def params():
  20. organization = Factories.create_organization()
  21. team = Factories.create_team(organization=organization)
  22. project1 = Factories.create_project(organization=organization, teams=[team])
  23. project2 = Factories.create_project(organization=organization, teams=[team])
  24. user = Factories.create_user()
  25. Factories.create_team_membership(team=team, user=user)
  26. return {
  27. "start": now - timedelta(days=7),
  28. "end": now - timedelta(seconds=1),
  29. "project_id": [project1.id, project2.id],
  30. "project_objects": [project1, project2],
  31. "organization_id": organization.id,
  32. "user_id": user.id,
  33. "team_id": [team.id],
  34. }
  35. def query_builder_fns(arg_name="query_builder_fn"):
  36. return pytest.mark.parametrize(
  37. arg_name,
  38. [
  39. pytest.param(ProfilesQueryBuilder, id="ProfilesQueryBuilder"),
  40. pytest.param(
  41. lambda **kwargs: ProfilesTimeseriesQueryBuilder(interval=60, **kwargs),
  42. id="ProfilesTimeseriesQueryBuilder",
  43. ),
  44. ],
  45. )
  46. @pytest.mark.parametrize(
  47. "field,resolved",
  48. [pytest.param(column.alias, column.column, id=column.alias) for column in PROFILE_COLUMNS],
  49. )
  50. @query_builder_fns()
  51. @django_db_all
  52. def test_field_resolution(query_builder_fn, params, field, resolved):
  53. builder = query_builder_fn(
  54. dataset=Dataset.Profiles,
  55. params=params,
  56. selected_columns=[field],
  57. )
  58. if field == resolved:
  59. assert builder.columns == [Column(field)]
  60. else:
  61. assert builder.columns == [AliasedExpression(Column(resolved), alias=field)]
  62. @pytest.mark.parametrize(
  63. "field,resolved",
  64. [
  65. pytest.param(
  66. "last_seen()",
  67. Function("max", parameters=[Column("received")], alias="last_seen"),
  68. id="last_seen()",
  69. ),
  70. pytest.param(
  71. "latest_event()",
  72. Function(
  73. "argMax",
  74. parameters=[Column("profile_id"), Column("received")],
  75. alias="latest_event",
  76. ),
  77. id="latest_event()",
  78. ),
  79. pytest.param("count()", Function("count", parameters=[], alias="count"), id="count()"),
  80. pytest.param(
  81. "count_unique(transaction)",
  82. Function(
  83. "uniq", parameters=[Column("transaction_name")], alias="count_unique_transaction"
  84. ),
  85. id="count_unique(transaction)",
  86. ),
  87. pytest.param(
  88. "percentile(profile.duration,0.25)",
  89. Function(
  90. "quantile(0.25)",
  91. parameters=[Column("duration_ns")],
  92. alias="percentile_profile_duration_0_25",
  93. ),
  94. id="percentile(profile.duration,0.25)",
  95. ),
  96. *[
  97. pytest.param(
  98. f"p{qt}()",
  99. Function(
  100. f"quantile(0.{qt.rstrip('0')})",
  101. parameters=[Column("duration_ns")],
  102. alias=f"p{qt}",
  103. ),
  104. id=f"p{qt}()",
  105. )
  106. for qt in ["50", "75", "95", "99"]
  107. ],
  108. pytest.param(
  109. "p100()",
  110. Function(
  111. "max",
  112. parameters=[Column("duration_ns")],
  113. alias="p100",
  114. ),
  115. id="p100()",
  116. ),
  117. *[
  118. pytest.param(
  119. f"p{qt}(profile.duration)",
  120. Function(
  121. f"quantile(0.{qt.rstrip('0')})",
  122. parameters=[Column("duration_ns")],
  123. alias=f"p{qt}_profile_duration",
  124. ),
  125. id=f"p{qt}(profile.duration)",
  126. )
  127. for qt in ["50", "75", "95", "99"]
  128. ],
  129. pytest.param(
  130. "p100(profile.duration)",
  131. Function(
  132. "max",
  133. parameters=[Column("duration_ns")],
  134. alias="p100_profile_duration",
  135. ),
  136. id="p100(profile.duration)",
  137. ),
  138. *[
  139. pytest.param(
  140. f"{fn}(profile.duration)",
  141. Function(
  142. fn,
  143. parameters=[Column("duration_ns")],
  144. alias=f"{fn}_profile_duration",
  145. ),
  146. id=f"{fn}(profile.duration)",
  147. )
  148. for fn in ["min", "max", "avg", "sum"]
  149. ],
  150. ],
  151. )
  152. @query_builder_fns()
  153. @django_db_all
  154. def test_aggregate_resolution(query_builder_fn, params, field, resolved):
  155. builder = query_builder_fn(
  156. dataset=Dataset.Profiles,
  157. params=params,
  158. selected_columns=[field],
  159. )
  160. assert builder.columns == [resolved]
  161. @pytest.mark.parametrize(
  162. "field,message",
  163. [
  164. pytest.param("foo", "Unknown field: foo", id="foo"),
  165. pytest.param("count(id)", "count: expected 0 argument\\(s\\)", id="count(id)"),
  166. pytest.param(
  167. "count_unique(foo)",
  168. "count_unique: column argument invalid: foo is not a valid column",
  169. id="count_unique(foo)",
  170. ),
  171. *[
  172. pytest.param(
  173. f"p{qt}(foo)",
  174. f"p{qt}: column argument invalid: foo is not a valid column",
  175. id=f"p{qt}(foo)",
  176. )
  177. for qt in ["50", "75", "95", "99"]
  178. ],
  179. *[
  180. pytest.param(
  181. f"p{qt}(id)",
  182. f"p{qt}: column argument invalid: id is not a numeric column",
  183. id=f"p{qt}(id)",
  184. )
  185. for qt in ["50", "75", "95", "99"]
  186. ],
  187. pytest.param(
  188. "percentile(foo,0.25)",
  189. "percentile: column argument invalid: foo is not a valid column",
  190. id="percentile(foo,0.25)",
  191. ),
  192. pytest.param(
  193. "percentile(id,0.25)",
  194. "percentile: column argument invalid: id is not a numeric column",
  195. id="percentile(id,0.25)",
  196. ),
  197. *[
  198. pytest.param(
  199. f"{fn}(foo)",
  200. f"{fn}: column argument invalid: foo is not a valid column",
  201. id=f"{fn}(foo)",
  202. )
  203. for fn in ["min", "max", "avg", "sum"]
  204. ],
  205. *[
  206. pytest.param(
  207. f"{fn}(id)",
  208. f"{fn}: column argument invalid: id is not a numeric column",
  209. id=f"{fn}(id)",
  210. )
  211. for fn in ["min", "max", "avg", "sum"]
  212. ],
  213. ],
  214. )
  215. @query_builder_fns()
  216. @django_db_all
  217. def test_invalid_field_resolution(query_builder_fn, params, field, message):
  218. with pytest.raises(InvalidSearchQuery, match=message):
  219. query_builder_fn(
  220. dataset=Dataset.Profiles,
  221. params=params,
  222. selected_columns=[field],
  223. )
  224. def is_null(column: str) -> Function:
  225. return Function("isNull", parameters=[Column(column)])
  226. @pytest.mark.parametrize(
  227. "query,conditions",
  228. [
  229. pytest.param(
  230. "project.id:1", [Condition(Column("project_id"), Op.EQ, 1.0)], id="project.id:1"
  231. ),
  232. pytest.param(
  233. "!project.id:1",
  234. [Condition(Column("project_id"), Op.NEQ, 1.0)],
  235. id="!project.id:1",
  236. ),
  237. pytest.param(
  238. f"trace.transaction:{'a' * 32}",
  239. [Condition(Column("transaction_id"), Op.EQ, "a" * 32)],
  240. id=f"trace.transaction:{'a' * 32}",
  241. ),
  242. pytest.param(
  243. f"!trace.transaction:{'a' * 32}",
  244. [Condition(Column("transaction_id"), Op.NEQ, "a" * 32)],
  245. id=f"!trace.transaction:{'a' * 32}",
  246. ),
  247. pytest.param(
  248. f"id:{'a' * 32}",
  249. [Condition(Column("profile_id"), Op.EQ, "a" * 32)],
  250. id=f"id:{'a' * 32}",
  251. ),
  252. pytest.param(
  253. f"!id:{'a' * 32}",
  254. [Condition(Column("profile_id"), Op.NEQ, "a" * 32)],
  255. id=f"!id:{'a' * 32}",
  256. ),
  257. pytest.param(
  258. f"timestamp:{today.isoformat()}",
  259. [
  260. # filtering for a timestamp means we search for a window around it
  261. Condition(Column("received"), Op.GTE, today - timedelta(minutes=5)),
  262. Condition(Column("received"), Op.LT, today + timedelta(minutes=6)),
  263. ],
  264. id=f"timestamp:{today.isoformat()}",
  265. ),
  266. pytest.param(
  267. f"!timestamp:{today.isoformat()}",
  268. [], # not sure what this should be yet
  269. id=f"!timestamp:{today.isoformat()}",
  270. marks=pytest.mark.xfail(reason="date filters cannot negated"),
  271. ),
  272. pytest.param(
  273. "device.arch:x86_64",
  274. [Condition(Column("architecture"), Op.EQ, "x86_64")],
  275. id="device.arch:x86_64",
  276. ),
  277. pytest.param(
  278. "!device.arch:x86_64",
  279. [Condition(Column("architecture"), Op.NEQ, "x86_64")],
  280. id="!device.arch:x86_64",
  281. ),
  282. pytest.param(
  283. "device.classification:high",
  284. [Condition(Column("device_classification"), Op.EQ, "high")],
  285. id="device.classification:high",
  286. ),
  287. pytest.param(
  288. "!device.classification:high",
  289. [Condition(Column("device_classification"), Op.NEQ, "high")],
  290. id="!device.classification:high",
  291. ),
  292. pytest.param(
  293. "device.locale:en_US",
  294. [Condition(Column("device_locale"), Op.EQ, "en_US")],
  295. id="device.locale:en_US",
  296. ),
  297. pytest.param(
  298. "!device.locale:en_US",
  299. [Condition(Column("device_locale"), Op.NEQ, "en_US")],
  300. id="!device.locale:en_US",
  301. ),
  302. pytest.param(
  303. "device.manufacturer:Apple",
  304. [Condition(Column("device_manufacturer"), Op.EQ, "Apple")],
  305. id="device.manufacturer:Apple",
  306. ),
  307. pytest.param(
  308. "!device.manufacturer:Apple",
  309. [Condition(Column("device_manufacturer"), Op.NEQ, "Apple")],
  310. id="!device.manufacturer:Apple",
  311. ),
  312. pytest.param(
  313. "device.model:iPhone14,2",
  314. [Condition(Column("device_model"), Op.EQ, "iPhone14,2")],
  315. id="device.model:iPhone14,2",
  316. ),
  317. pytest.param(
  318. "!device.model:iPhone14,2",
  319. [Condition(Column("device_model"), Op.NEQ, "iPhone14,2")],
  320. id="!device.model:iPhone14,2",
  321. ),
  322. pytest.param(
  323. "device.model:iPhone14,2",
  324. [Condition(Column("device_model"), Op.EQ, "iPhone14,2")],
  325. id="device.model:iPhone14,2",
  326. ),
  327. pytest.param(
  328. "os.build:20G817",
  329. [Condition(Column("device_os_build_number"), Op.EQ, "20G817")],
  330. id="os.build:20G817",
  331. ),
  332. pytest.param(
  333. "!os.build:20G817",
  334. [
  335. # os.build is a nullable column
  336. Or(
  337. conditions=[
  338. Condition(is_null("device_os_build_number"), Op.EQ, 1),
  339. Condition(Column("device_os_build_number"), Op.NEQ, "20G817"),
  340. ]
  341. )
  342. ],
  343. id="!os.build:20G817",
  344. ),
  345. pytest.param(
  346. "os.name:iOS",
  347. [Condition(Column("device_os_name"), Op.EQ, "iOS")],
  348. id="os.name:iOS",
  349. ),
  350. pytest.param(
  351. "!os.name:iOS",
  352. [Condition(Column("device_os_name"), Op.NEQ, "iOS")],
  353. id="!os.name:iOS",
  354. ),
  355. pytest.param(
  356. "os.version:15.2",
  357. [Condition(Column("device_os_version"), Op.EQ, "15.2")],
  358. id="os.version:15.2",
  359. ),
  360. pytest.param(
  361. "!os.version:15.2",
  362. [Condition(Column("device_os_version"), Op.NEQ, "15.2")],
  363. id="!os.version:15.2",
  364. ),
  365. pytest.param(
  366. "profile.duration:1",
  367. # since 1 mean 1 millisecond, and converted to nanoseconds its 1e6
  368. [Condition(Column("duration_ns"), Op.EQ, 1e6)],
  369. id="profile.duration:1",
  370. ),
  371. pytest.param(
  372. "!profile.duration:1",
  373. # since 1 mean 1 millisecond, and converted to nanoseconds its 1e6
  374. [Condition(Column("duration_ns"), Op.NEQ, 1e6)],
  375. id="!profile.duration:1",
  376. ),
  377. pytest.param(
  378. "profile.duration:>1",
  379. # since 1 mean 1 millisecond, and converted to nanoseconds its 1e6
  380. [Condition(Column("duration_ns"), Op.GT, 1e6)],
  381. id="profile.duration:>1",
  382. ),
  383. pytest.param(
  384. "profile.duration:<1",
  385. # since 1 mean 1 millisecond, and converted to nanoseconds its 1e6
  386. [Condition(Column("duration_ns"), Op.LT, 1e6)],
  387. id="profile.duration:<1",
  388. ),
  389. pytest.param(
  390. "profile.duration:1s",
  391. # since 1s mean 1 second, and converted to nanoseconds its 1e9
  392. [Condition(Column("duration_ns"), Op.EQ, 1e9)],
  393. id="profile.duration:1s",
  394. ),
  395. pytest.param(
  396. "environment:dev",
  397. [Condition(Column("environment"), Op.EQ, "dev")],
  398. id="environment:dev",
  399. ),
  400. pytest.param(
  401. "!environment:dev",
  402. [
  403. # environment is a nullable column
  404. Or(
  405. conditions=[
  406. Condition(is_null("environment"), Op.EQ, 1),
  407. Condition(Column("environment"), Op.NEQ, "dev"),
  408. ]
  409. )
  410. ],
  411. id="!environment:dev",
  412. ),
  413. pytest.param(
  414. "platform.name:cocoa",
  415. [Condition(Column("platform"), Op.EQ, "cocoa")],
  416. id="platform.name:cocoa",
  417. ),
  418. pytest.param(
  419. "!platform.name:cocoa",
  420. [Condition(Column("platform"), Op.NEQ, "cocoa")],
  421. id="!platform.name:cocoa",
  422. ),
  423. pytest.param(
  424. f"trace:{'a' * 32}",
  425. [Condition(Column("trace_id"), Op.EQ, "a" * 32)],
  426. id=f"trace:{'a' * 32}",
  427. ),
  428. pytest.param(
  429. f"!trace:{'a' * 32}",
  430. [Condition(Column("trace_id"), Op.NEQ, "a" * 32)],
  431. id=f"!trace:{'a' * 32}",
  432. ),
  433. pytest.param(
  434. "transaction:foo",
  435. [Condition(Column("transaction_name"), Op.EQ, "foo")],
  436. id="transaction:foo",
  437. ),
  438. pytest.param(
  439. "!transaction:foo",
  440. [Condition(Column("transaction_name"), Op.NEQ, "foo")],
  441. id="!transaction:foo",
  442. ),
  443. pytest.param(
  444. "release:foo",
  445. [Condition(Column("version_name"), Op.EQ, "foo")],
  446. id="release:foo",
  447. ),
  448. pytest.param(
  449. "!release:foo",
  450. [Condition(Column("version_name"), Op.NEQ, "foo")],
  451. id="!release:foo",
  452. ),
  453. pytest.param(
  454. "project_id:1",
  455. [Condition(Column("project_id"), Op.EQ, 1)],
  456. id="project_id:1",
  457. ),
  458. pytest.param(
  459. "!project_id:1",
  460. [Condition(Column("project_id"), Op.NEQ, 1)],
  461. id="!project_id:1",
  462. ),
  463. pytest.param(
  464. "foo",
  465. [
  466. Condition(
  467. Function("positionCaseInsensitive", [Column("transaction_name"), "foo"]),
  468. Op.NEQ,
  469. 0,
  470. )
  471. ],
  472. id="foo",
  473. ),
  474. ],
  475. )
  476. @django_db_all
  477. def test_where_resolution(params, query, conditions):
  478. builder = ProfilesQueryBuilder(
  479. dataset=Dataset.Profiles,
  480. params=params,
  481. selected_columns=["count()"],
  482. query=query,
  483. )
  484. for condition in conditions:
  485. assert condition in builder.where, condition
  486. @pytest.mark.parametrize("field", [pytest.param("project"), pytest.param("project.name")])
  487. @django_db_all
  488. def test_where_resolution_project_slug(params, field):
  489. project = params["project_objects"][0]
  490. builder = ProfilesQueryBuilder(
  491. dataset=Dataset.Profiles,
  492. params=params,
  493. selected_columns=["count()"],
  494. query=f"{field}:{project.slug}",
  495. )
  496. assert Condition(Column("project_id"), Op.EQ, project.id) in builder.where
  497. builder = ProfilesQueryBuilder(
  498. dataset=Dataset.Profiles,
  499. params=params,
  500. selected_columns=["count()"],
  501. query=f"!{field}:{project.slug}",
  502. )
  503. assert Condition(Column("project_id"), Op.NEQ, project.id) in builder.where
  504. @pytest.mark.parametrize("field", [pytest.param("project"), pytest.param("project.name")])
  505. @pytest.mark.parametrize("direction", [pytest.param("", id="asc"), pytest.param("-", id="desc")])
  506. @django_db_all
  507. def test_order_by_resolution_project_slug(params, field, direction):
  508. builder = ProfilesQueryBuilder(
  509. dataset=Dataset.Profiles,
  510. params=params,
  511. selected_columns=[field, "count()"],
  512. orderby=f"{direction}{field}",
  513. )
  514. assert (
  515. OrderBy(
  516. Function(
  517. "transform",
  518. [
  519. Column("project_id"),
  520. [project.id for project in params["project_objects"]],
  521. [project.slug for project in params["project_objects"]],
  522. "",
  523. ],
  524. ),
  525. Direction.ASC if direction == "" else Direction.DESC,
  526. )
  527. in builder.orderby
  528. )
  529. @pytest.mark.parametrize(
  530. "field,column",
  531. [
  532. pytest.param(
  533. column.alias,
  534. column.column,
  535. id=f"has:{column.alias}",
  536. marks=pytest.mark.skip(reason="has not working yet"),
  537. )
  538. for column in PROFILE_COLUMNS
  539. ],
  540. )
  541. @django_db_all
  542. def test_has_resolution(params, field, column):
  543. builder = ProfilesQueryBuilder(
  544. dataset=Dataset.Profiles,
  545. params=params,
  546. selected_columns=["count()"],
  547. query=f"has:{field}",
  548. )
  549. if field in ProfilesDatasetConfig.non_nullable_keys:
  550. assert Condition(Column(column), Op.NEQ, "") in builder.where
  551. else:
  552. assert Condition(is_null(column), Op.NEQ, 1) in builder.where
  553. @pytest.mark.parametrize(
  554. "field,column",
  555. [
  556. pytest.param(
  557. column.alias,
  558. column.column,
  559. id=f"!has:{column.alias}",
  560. marks=pytest.mark.skip(reason="!has not working yet"),
  561. )
  562. for column in PROFILE_COLUMNS
  563. ],
  564. )
  565. @django_db_all
  566. def test_not_has_resolution(params, field, column):
  567. builder = ProfilesQueryBuilder(
  568. dataset=Dataset.Profiles,
  569. params=params,
  570. selected_columns=["count()"],
  571. query=f"!has:{field}",
  572. )
  573. if field in ProfilesDatasetConfig.non_nullable_keys:
  574. assert Condition(Column(column), Op.EQ, "") in builder.where
  575. else:
  576. assert Condition(is_null(column), Op.EQ, 1) in builder.where