tests.py 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650
  1. import json
  2. import random
  3. from collections.abc import Iterable, Mapping
  4. from typing import Optional
  5. from unittest.mock import patch
  6. from django.shortcuts import reverse
  7. from django.test import override_settings
  8. from model_bakery import baker
  9. from prometheus_client import Metric
  10. from prometheus_client.parser import text_string_to_metric_families
  11. from rest_framework.test import APITestCase
  12. from environments.models import Environment, EnvironmentProject
  13. from glitchtip.test_utils import generators # pylint: disable=unused-import
  14. from glitchtip.test_utils.test_case import GlitchTipTestCase
  15. from issues.models import EventStatus, Issue
  16. from observability.metrics import events_counter, issues_counter
  17. from releases.models import Release
  18. from ..models import Event, LogLevel
  19. from ..test_data.csp import mdn_sample_csp
  20. class EventStoreTestCase(APITestCase):
  21. def setUp(self):
  22. self.project = baker.make("projects.Project")
  23. self.projectkey = self.project.projectkey_set.first()
  24. self.params = f"?sentry_key={self.projectkey.public_key}"
  25. self.url = reverse("event_store", args=[self.project.id]) + self.params
  26. def test_store_api(self):
  27. with open("events/test_data/py_hi_event.json") as json_file:
  28. data = json.load(json_file)
  29. res = self.client.post(self.url, data, format="json")
  30. self.assertEqual(res.status_code, 200)
  31. def test_maintenance_freeze(self):
  32. with open("events/test_data/py_hi_event.json") as json_file:
  33. data = json.load(json_file)
  34. with override_settings(MAINTENANCE_EVENT_FREEZE=True):
  35. res = self.client.post(self.url, data, format="json")
  36. self.assertEqual(res.status_code, 503)
  37. def test_store_duplicate(self):
  38. with open("events/test_data/py_hi_event.json") as json_file:
  39. data = json.load(json_file)
  40. self.client.post(self.url, data, format="json")
  41. res = self.client.post(self.url, data, format="json")
  42. self.assertContains(res, "ID already exist", status_code=403)
  43. def test_store_invalid_key(self):
  44. with open("events/test_data/py_hi_event.json") as json_file:
  45. data = json.load(json_file)
  46. self.client.post(self.url, data, format="json")
  47. res = self.client.post(self.url, data, format="json")
  48. self.assertContains(res, "ID already exist", status_code=403)
  49. def test_store_api_auth_failure(self):
  50. url = "/api/1/store/"
  51. with open("events/test_data/py_hi_event.json") as json_file:
  52. data = json.load(json_file)
  53. params = "?sentry_key=aaa"
  54. url = reverse("event_store", args=[self.project.id]) + params
  55. res = self.client.post(url, data, format="json")
  56. self.assertEqual(res.status_code, 401)
  57. params = "?sentry_key=238df2aac6331578a16c14bcb3db5259"
  58. url = reverse("event_store", args=[self.project.id]) + params
  59. res = self.client.post(url, data, format="json")
  60. self.assertContains(res, "Invalid api key", status_code=401)
  61. url = reverse("event_store", args=[10000]) + self.params
  62. res = self.client.post(url, data, format="json")
  63. self.assertContains(res, "Invalid project_id", status_code=400)
  64. def test_error_event(self):
  65. with open("events/test_data/py_error.json") as json_file:
  66. data = json.load(json_file)
  67. res = self.client.post(self.url, data, format="json")
  68. self.assertEqual(res.status_code, 200)
  69. def test_csp_event(self):
  70. url = reverse("csp_store", args=[self.project.id]) + self.params
  71. data = mdn_sample_csp
  72. res = self.client.post(url, data, format="json")
  73. self.assertEqual(res.status_code, 200)
  74. expected_title = "Blocked 'style' from 'example.com'"
  75. issue = Issue.objects.get(title=expected_title)
  76. event = Event.objects.get()
  77. self.assertEqual(event.data["csp"]["effective_directive"], "style-src")
  78. self.assertTrue(issue)
  79. def test_reopen_resolved_issue(self):
  80. with open("events/test_data/py_hi_event.json") as json_file:
  81. data = json.load(json_file)
  82. self.client.post(self.url, data, format="json")
  83. issue = Issue.objects.all().first()
  84. issue.status = EventStatus.RESOLVED
  85. issue.save()
  86. data["event_id"] = "6600a066e64b4caf8ed7ec5af64ac4ba"
  87. self.client.post(self.url, data, format="json")
  88. issue.refresh_from_db()
  89. self.assertEqual(issue.status, EventStatus.UNRESOLVED)
  90. def test_issue_count(self):
  91. with open("events/test_data/py_hi_event.json") as json_file:
  92. data = json.load(json_file)
  93. self.client.post(self.url, data, format="json")
  94. issue = Issue.objects.first()
  95. self.assertEqual(issue.count, 1)
  96. data["event_id"] = "6600a066e64b4caf8ed7ec5af64ac4ba"
  97. self.client.post(self.url, data, format="json")
  98. issue.refresh_from_db()
  99. self.assertEqual(issue.count, 2)
  100. def test_performance(self):
  101. with open("events/test_data/py_hi_event.json") as json_file:
  102. data = json.load(json_file)
  103. with self.assertNumQueries(18):
  104. res = self.client.post(self.url, data, format="json")
  105. self.assertEqual(res.status_code, 200)
  106. # Second event should have less queries
  107. data["event_id"] = "6600a066e64b4caf8ed7ec5af64ac4bb"
  108. with self.assertNumQueries(10):
  109. res = self.client.post(self.url, data, format="json")
  110. self.assertEqual(res.status_code, 200)
  111. def test_throttle_organization(self):
  112. organization = self.project.organization
  113. organization.is_accepting_events = False
  114. organization.save()
  115. with open("events/test_data/py_hi_event.json") as json_file:
  116. data = json.load(json_file)
  117. res = self.client.post(self.url, data, format="json")
  118. self.assertEqual(res.status_code, 429)
  119. def test_project_first_event(self):
  120. with open("events/test_data/py_error.json") as json_file:
  121. data = json.load(json_file)
  122. self.assertFalse(self.project.first_event)
  123. self.client.post(self.url, data, format="json")
  124. self.project.refresh_from_db()
  125. self.assertTrue(self.project.first_event)
  126. def test_null_character_event(self):
  127. """
  128. Unicode null characters \u0000 are not supported by Postgres JSONB
  129. NUL \x00 characters are not supported by Postgres string types
  130. They should be filtered out
  131. """
  132. with open("events/test_data/py_error.json") as json_file:
  133. data = json.load(json_file)
  134. data["exception"]["values"][0]["stacktrace"]["frames"][0][
  135. "function"
  136. ] = "a\u0000a"
  137. data["exception"]["values"][0]["value"] = "\x00\u0000"
  138. res = self.client.post(self.url, data, format="json")
  139. self.assertEqual(res.status_code, 200)
  140. def test_header_value_array(self):
  141. """
  142. Request Header values are both strings and arrays (sentry-php uses arrays)
  143. """
  144. with open("events/test_data/py_error.json") as json_file:
  145. data = json.load(json_file)
  146. data["request"]["headers"]["Content-Type"] = ["text/plain"]
  147. res = self.client.post(self.url, data, format="json")
  148. self.assertEqual(res.status_code, 200)
  149. event = Event.objects.first()
  150. header = next(
  151. x for x in event.data["request"]["headers"] if x[0] == "Content-Type"
  152. )
  153. self.assertTrue(isinstance(header[1], str))
  154. def test_anonymize_ip(self):
  155. """ip address should get masked because default project settings are to scrub ip address"""
  156. with open("events/test_data/py_hi_event.json") as json_file:
  157. data = json.load(json_file)
  158. test_ip = "123.168.29.14"
  159. res = self.client.post(self.url, data, format="json", REMOTE_ADDR=test_ip)
  160. self.assertEqual(res.status_code, 200)
  161. event = Event.objects.first()
  162. self.assertNotEqual(event.data["user"]["ip_address"], test_ip)
  163. def test_csp_event_anonymize_ip(self):
  164. url = reverse("csp_store", args=[self.project.id]) + self.params
  165. test_ip = "123.168.29.14"
  166. data = mdn_sample_csp
  167. res = self.client.post(url, data, format="json", REMOTE_ADDR=test_ip)
  168. self.assertEqual(res.status_code, 200)
  169. event = Event.objects.first()
  170. self.assertNotEqual(event.data["user"]["ip_address"], test_ip)
  171. def test_store_very_large_data(self):
  172. """
  173. This test is expected to exceed the 1mb limit of a postgres tsvector
  174. """
  175. with open("events/test_data/py_hi_event.json") as json_file:
  176. data = json.load(json_file)
  177. data["platform"] = " ".join([str(random.random()) for _ in range(50000)])
  178. res = self.client.post(self.url, data, format="json")
  179. self.assertEqual(res.status_code, 200)
  180. self.assertEqual(
  181. Issue.objects.first().search_vector,
  182. None,
  183. "No tsvector is expected as it would exceed the Postgres limit",
  184. )
  185. data["event_id"] = "6600a066e64b4caf8ed7ec5af64ac4be"
  186. res = self.client.post(self.url, data, format="json")
  187. self.assertEqual(res.status_code, 200)
  188. def test_store_somewhat_large_data(self):
  189. """
  190. This test is expected to exceed the 1mb limit of a postgres tsvector
  191. only when two events exist for 1 issue.
  192. """
  193. with open("events/test_data/py_hi_event.json") as json_file:
  194. data = json.load(json_file)
  195. data["platform"] = " ".join([str(random.random()) for _ in range(30000)])
  196. res = self.client.post(self.url, data, format="json")
  197. self.assertEqual(res.status_code, 200)
  198. data["event_id"] = "6600a066e64b4caf8ed7ec5af64ac4be"
  199. data["platform"] = " ".join([str(random.random()) for _ in range(30000)])
  200. res = self.client.post(self.url, data, format="json")
  201. self.assertEqual(res.status_code, 200)
  202. self.assertTrue(
  203. Issue.objects.first().search_vector,
  204. "tsvector is expected",
  205. )
  206. @patch("events.views.logger")
  207. def test_invalid_event(self, mock_logger):
  208. with open("events/test_data/py_hi_event.json") as json_file:
  209. data = json.load(json_file)
  210. data["transaction"] = True
  211. res = self.client.post(self.url, data, format="json")
  212. self.assertEqual(res.status_code, 200)
  213. mock_logger.warning.assert_called()
  214. def test_breadcrumbs_object(self):
  215. """Event breadcrumbs may be sent as an array or a object."""
  216. with open("events/test_data/py_hi_event.json") as json_file:
  217. data = json.load(json_file)
  218. data["breadcrumbs"] = {
  219. "values": [
  220. {
  221. "timestamp": "2020-01-20T20:00:00.000Z",
  222. "message": "Something",
  223. "category": "log",
  224. "data": {"foo": "bar"},
  225. },
  226. ]
  227. }
  228. res = self.client.post(self.url, data, format="json")
  229. self.assertEqual(res.status_code, 200)
  230. self.assertTrue(Issue.objects.exists())
  231. def test_event_release(self):
  232. with open("events/test_data/py_hi_event.json") as json_file:
  233. data = json.load(json_file)
  234. baker.make("releases.Release", version=data.get("release"))
  235. self.client.post(self.url, data, format="json")
  236. event = Event.objects.first()
  237. event_json = event.event_json()
  238. self.assertTrue(event.release)
  239. self.assertEqual(event_json.get("release"), event.release.version)
  240. self.assertIn(
  241. event.release.version,
  242. dict(event_json.get("tags")).values(),
  243. )
  244. self.assertTrue(
  245. Release.objects.filter(
  246. version=data.get("release"), projects=self.project
  247. ).exists()
  248. )
  249. def test_event_release_blank(self):
  250. """In the SDK, it's possible to set a release to a blank string"""
  251. with open("events/test_data/py_hi_event.json") as json_file:
  252. data = json.load(json_file)
  253. data["release"] = ""
  254. res = self.client.post(self.url, data, format="json")
  255. self.assertEqual(res.status_code, 200)
  256. self.assertTrue(Event.objects.first())
  257. def test_client_tags(self):
  258. with open("events/test_data/py_hi_event.json") as json_file:
  259. data = json.load(json_file)
  260. data["tags"] = {"test_tag": "the value"}
  261. self.client.post(self.url, data, format="json")
  262. event = Event.objects.first()
  263. event_json = event.event_json()
  264. self.assertIn(
  265. "the value",
  266. tuple(event_json.get("tags"))[1],
  267. )
  268. def test_client_tags_invalid(self):
  269. """Invalid tags should not be saved. But should not error."""
  270. with open("events/test_data/py_hi_event.json") as json_file:
  271. data = json.load(json_file)
  272. data["tags"] = {
  273. "value": "valid value",
  274. "my invalid tag key": {"oh": "this is invalid"},
  275. }
  276. res = self.client.post(self.url, data, format="json")
  277. event = Event.objects.first()
  278. self.assertEqual(res.status_code, 200)
  279. self.assertTrue(event)
  280. event_json = event.event_json()
  281. tags = tuple(event_json.get("tags"))
  282. self.assertIn(
  283. "valid value",
  284. tags[0],
  285. )
  286. for tag in tags:
  287. self.assertNotIn("this is invalid", tag)
  288. self.assertEqual(len(event_json.get("errors")), 1)
  289. def test_malformed_exception_value(self):
  290. """Malformed exception values aren't 100% supported, but should stored anyway"""
  291. with open("events/test_data/py_error.json") as json_file:
  292. data = json.load(json_file)
  293. data["exception"]["values"][0]["value"] = {"why is this": "any object?"}
  294. res = self.client.post(self.url, data, format="json")
  295. self.assertEqual(res.status_code, 200)
  296. def test_no_sdk(self):
  297. data = {
  298. "exception": [
  299. {
  300. "type": "Plug.Parsers.ParseError",
  301. "value": "malformed request",
  302. "module": None,
  303. }
  304. ],
  305. "culprit": "Plug.Parsers.JSON.decode",
  306. "extra": {},
  307. "event_id": "11111111111111111111111111111111",
  308. "breadcrumbs": [],
  309. "level": "error",
  310. "modules": {
  311. "cowboy": "2.8.0",
  312. },
  313. "fingerprint": ["{{ default }}"],
  314. "message": "(Plug.Parsers.ParseError) malformed",
  315. }
  316. res = self.client.post(self.url, data, format="json")
  317. self.assertEqual(res.status_code, 200)
  318. self.assertTrue(Event.objects.exists())
  319. def test_invalid_level(self):
  320. data = {
  321. "exception": [
  322. {
  323. "type": "a",
  324. "value": "a",
  325. "module": None,
  326. }
  327. ],
  328. "culprit": "a",
  329. "extra": {},
  330. "event_id": "11111111111111111111111111111111",
  331. "breadcrumbs": [],
  332. "level": "haha",
  333. "message": "a",
  334. }
  335. res = self.client.post(self.url, data, format="json")
  336. self.assertEqual(res.status_code, 200)
  337. self.assertTrue(Event.objects.filter(level=LogLevel.ERROR).exists())
  338. def test_null_release(self):
  339. data = {
  340. "exception": [
  341. {
  342. "type": "a",
  343. "value": "a",
  344. "module": None,
  345. }
  346. ],
  347. "culprit": "a",
  348. "extra": {},
  349. "event_id": "11111111111111111111111111111111",
  350. "breadcrumbs": [],
  351. "level": "haha",
  352. "message": "",
  353. "release": None,
  354. "environment": None,
  355. "request": {"env": {"FOO": None}},
  356. }
  357. res = self.client.post(self.url, data, format="json")
  358. self.assertEqual(res.status_code, 200)
  359. self.assertTrue(Event.objects.filter().exists())
  360. def test_formatted_message(self):
  361. data = {
  362. "exception": [
  363. {
  364. "type": "a",
  365. "value": "a",
  366. "module": None,
  367. }
  368. ],
  369. "event_id": "11111111111111111111111111111111",
  370. "message": {"formatted": "Hello"},
  371. }
  372. res = self.client.post(self.url, data, format="json")
  373. self.assertTrue(Event.objects.filter(data__message="Hello").exists())
  374. def test_invalid_message(self):
  375. # It's actually accepted as is. Considered to be message: ""
  376. data = {
  377. "exception": [
  378. {
  379. "type": "a",
  380. "value": "a",
  381. "module": None,
  382. }
  383. ],
  384. "event_id": "11111111111111111111111111111111",
  385. "message": {},
  386. }
  387. res = self.client.post(self.url, data, format="json")
  388. self.assertTrue(Event.objects.filter(data__message="").exists())
  389. def test_null_message(self):
  390. data = {
  391. "exception": [{}],
  392. "event_id": "11111111111111111111111111111111",
  393. "message": None,
  394. }
  395. res = self.client.post(self.url, data, format="json")
  396. self.assertTrue(Event.objects.filter(data__message=None).exists())
  397. def test_long_environment(self):
  398. data = {
  399. "exception": [
  400. {
  401. "type": "a",
  402. "value": "a",
  403. "module": None,
  404. }
  405. ],
  406. "event_id": "11111111111111111111111111111111",
  407. "environment": "a" * 257,
  408. }
  409. res = self.client.post(self.url, data, format="json")
  410. self.assertTrue(Event.objects.filter().exists())
  411. def test_repeat_environment(self):
  412. existing_environment = baker.make("environments.Environment", name="staging")
  413. data = {
  414. "exception": [
  415. {
  416. "type": "a",
  417. "value": "a",
  418. "module": None,
  419. }
  420. ],
  421. "event_id": "11111111111111111111111111111111",
  422. "environment": existing_environment.name,
  423. }
  424. res = self.client.post(self.url, data, format="json")
  425. self.assertTrue(
  426. EnvironmentProject.objects.filter(
  427. environment__name=existing_environment.name, project=self.project
  428. ).exists()
  429. )
  430. def test_invalid_environment(self):
  431. data = {
  432. "exception": [
  433. {
  434. "type": "a",
  435. "value": "a",
  436. "module": None,
  437. }
  438. ],
  439. "event_id": "11111111111111111111111111111111",
  440. "environment": "a/a",
  441. }
  442. res = self.client.post(self.url, data, format="json")
  443. self.assertTrue(Event.objects.filter().exists())
  444. self.assertFalse(Environment.objects.exists())
  445. def test_query_string_formats(self):
  446. data = {
  447. "event_id": "11111111111111111111111111111111",
  448. "exception": [
  449. {
  450. "type": "a",
  451. "value": "a",
  452. "module": None,
  453. }
  454. ],
  455. "request": {
  456. "method": "GET",
  457. "query_string": {"search": "foo"},
  458. },
  459. }
  460. self.client.post(self.url, data, format="json")
  461. data = {
  462. "event_id": "11111111111111111111111111111112",
  463. "exception": [
  464. {
  465. "type": "a",
  466. "value": "a",
  467. "module": None,
  468. }
  469. ],
  470. "request": {
  471. "query_string": "search=foo",
  472. },
  473. }
  474. self.client.post(self.url, data, format="json")
  475. data = {
  476. "event_id": "11111111111111111111111111111113",
  477. "exception": [
  478. {
  479. "type": "a",
  480. "value": "a",
  481. "module": None,
  482. }
  483. ],
  484. "request": {"query_string": [["search", "foo"]]},
  485. }
  486. self.client.post(self.url, data, format="json")
  487. self.assertEqual(
  488. Event.objects.filter(
  489. data__request__query_string=[["search", "foo"]]
  490. ).count(),
  491. 3,
  492. )
  493. def get_sample_value(
  494. metric_families: Iterable[Metric],
  495. metric_name: str,
  496. metric_type: str,
  497. labels: Mapping[str, str],
  498. ) -> Optional[float]:
  499. for metric_family in metric_families:
  500. if metric_family.name != metric_name or metric_family.type != metric_type:
  501. continue
  502. for metric in metric_family.samples:
  503. if metric[1] != labels:
  504. continue
  505. return metric.value
  506. return None
  507. def parse_prometheus_text(text: str) -> list[Metric]:
  508. parser = text_string_to_metric_families(text)
  509. return list(parser)
  510. class EventMetricTestCase(GlitchTipTestCase):
  511. def setUp(self):
  512. self.create_user_and_project()
  513. self.user.is_staff = True
  514. self.user.save()
  515. self.metrics_url = reverse("prometheus-django-metrics")
  516. self.projectkey = self.project.projectkey_set.first()
  517. self.params = f"?sentry_key={self.projectkey.public_key}"
  518. self.events_url = reverse("event_store", args=[self.project.id]) + self.params
  519. def test_metrics(self):
  520. with open("events/test_data/py_hi_event.json") as json_file:
  521. data = json.load(json_file)
  522. event_metric_labels = {
  523. "project": self.project.slug,
  524. "organization": self.project.organization.slug,
  525. "issue": "hi",
  526. }
  527. issue_metric_labels = {
  528. "project": self.project.slug,
  529. "organization": self.project.organization.slug,
  530. }
  531. # get initial metrics
  532. metric_res = self.client.get(self.metrics_url)
  533. self.assertEqual(metric_res.status_code, 200)
  534. metrics = parse_prometheus_text(metric_res.content.decode("utf-8"))
  535. events_before = get_sample_value(
  536. metrics, events_counter._name, events_counter._type, event_metric_labels
  537. )
  538. # no events yet
  539. self.assertEqual(events_before, None)
  540. issues_before = get_sample_value(
  541. metrics, issues_counter._name, issues_counter._type, issue_metric_labels
  542. )
  543. # no issues yet
  544. self.assertEqual(issues_before, None)
  545. # send event
  546. res = self.client.post(self.events_url, data, format="json")
  547. self.assertEqual(res.status_code, 200)
  548. # get latest metrics
  549. metric_res = self.client.get(self.metrics_url)
  550. self.assertEqual(metric_res.status_code, 200)
  551. metrics = parse_prometheus_text(metric_res.content.decode("utf-8"))
  552. events_after = get_sample_value(
  553. metrics, events_counter._name, events_counter._type, event_metric_labels
  554. )
  555. self.assertEqual(events_after, 1)
  556. issues_after = get_sample_value(
  557. metrics, issues_counter._name, issues_counter._type, issue_metric_labels
  558. )
  559. self.assertEqual(issues_after, 1)
  560. # Second event should not increase the issue count
  561. data["event_id"] = "6600a066e64b4caf8ed7ec5af64ac4bb"
  562. res = self.client.post(self.events_url, data, format="json")
  563. self.assertEqual(res.status_code, 200)
  564. # get latest metrics
  565. metric_res = self.client.get(self.metrics_url)
  566. self.assertEqual(metric_res.status_code, 200)
  567. metrics = parse_prometheus_text(metric_res.content.decode("utf-8"))
  568. events_after = get_sample_value(
  569. metrics, events_counter._name, events_counter._type, event_metric_labels
  570. )
  571. # new event
  572. self.assertEqual(events_after, 2)
  573. issues_after = get_sample_value(
  574. metrics, issues_counter._name, issues_counter._type, issue_metric_labels
  575. )
  576. # but no new issue
  577. self.assertEqual(issues_after, 1)