mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
629 lines
24 KiB
Python
629 lines
24 KiB
Python
from datetime import datetime
|
|
from unittest import mock
|
|
|
|
from sentry.api.serializers import SimpleEventSerializer, serialize
|
|
from sentry.api.serializers.models.event import (
|
|
IssueEventSerializer,
|
|
SharedEventSerializer,
|
|
SqlFormatEventSerializer,
|
|
)
|
|
from sentry.api.serializers.rest_framework import convert_dict_key_case, snake_to_camel_case
|
|
from sentry.models.eventerror import EventError
|
|
from sentry.models.release import Release
|
|
from sentry.sdk_updates import SdkIndexState
|
|
from sentry.testutils.cases import TestCase
|
|
from sentry.testutils.helpers.datetime import before_now, iso_format, timestamp_format
|
|
from sentry.testutils.performance_issues.event_generators import get_event
|
|
from sentry.testutils.silo import region_silo_test
|
|
from sentry.testutils.skips import requires_snuba
|
|
from sentry.utils.samples import load_data
|
|
from tests.sentry.issues.test_utils import OccurrenceTestMixin
|
|
|
|
pytestmark = [requires_snuba]
|
|
|
|
|
|
@region_silo_test
|
|
class EventSerializerTest(TestCase, OccurrenceTestMixin):
|
|
def test_simple(self):
|
|
event_id = "a" * 32
|
|
event = self.store_event(
|
|
data={"event_id": event_id, "timestamp": iso_format(before_now(minutes=1))},
|
|
project_id=self.project.id,
|
|
)
|
|
result = serialize(event)
|
|
assert result["id"] == event_id
|
|
assert result["eventID"] == event_id
|
|
assert result["occurrence"] is None
|
|
|
|
def test_eventerror(self):
|
|
event = self.store_event(
|
|
data={
|
|
"event_id": "a" * 32,
|
|
"timestamp": iso_format(before_now(minutes=1)),
|
|
"stacktrace": ["ü"],
|
|
},
|
|
project_id=self.project.id,
|
|
assert_no_errors=False,
|
|
)
|
|
|
|
result = serialize(event)
|
|
assert len(result["errors"]) == 1
|
|
assert "data" in result["errors"][0]
|
|
assert result["errors"][0]["type"] == EventError.INVALID_DATA
|
|
assert result["errors"][0]["data"] == {
|
|
"name": "stacktrace",
|
|
"reason": "expected rawstacktrace",
|
|
"value": ["\xfc"],
|
|
}
|
|
assert "startTimestamp" not in result
|
|
assert "timestamp" not in result
|
|
|
|
def test_hidden_eventerror(self):
|
|
event = self.store_event(
|
|
data={
|
|
"event_id": "a" * 32,
|
|
"timestamp": iso_format(before_now(minutes=1)),
|
|
"breadcrumbs": ["ü"],
|
|
},
|
|
project_id=self.project.id,
|
|
assert_no_errors=False,
|
|
)
|
|
|
|
result = serialize(event)
|
|
assert result["errors"] == []
|
|
|
|
def test_renamed_attributes(self):
|
|
# Only includes meta for simple top-level attributes
|
|
event = self.store_event(
|
|
data={
|
|
"event_id": "a" * 32,
|
|
"timestamp": iso_format(before_now(minutes=1)),
|
|
"extra": {"extra": True},
|
|
"modules": {"modules": "foobar"},
|
|
"_meta": {
|
|
"extra": {"": {"err": ["extra error"]}},
|
|
"modules": {"": {"err": ["modules error"]}},
|
|
},
|
|
},
|
|
project_id=self.project.id,
|
|
assert_no_errors=False,
|
|
)
|
|
|
|
result = serialize(event)
|
|
assert result["context"] == {"extra": True}
|
|
assert result["_meta"]["context"] == {"": {"err": ["extra error"]}}
|
|
assert result["packages"] == {"modules": "foobar"}
|
|
assert result["_meta"]["packages"] == {"": {"err": ["modules error"]}}
|
|
|
|
def test_message_interface(self):
|
|
event = self.store_event(
|
|
data={
|
|
"event_id": "a" * 32,
|
|
"timestamp": iso_format(before_now(minutes=1)),
|
|
"logentry": {"formatted": "bar"},
|
|
"_meta": {"logentry": {"formatted": {"": {"err": ["some error"]}}}},
|
|
},
|
|
project_id=self.project.id,
|
|
assert_no_errors=False,
|
|
)
|
|
|
|
result = serialize(event)
|
|
assert result["message"] == "bar"
|
|
assert result["_meta"]["message"] == {"": {"err": ["some error"]}}
|
|
|
|
def test_message_formatted(self):
|
|
event = self.store_event(
|
|
data={
|
|
"event_id": "a" * 32,
|
|
"timestamp": iso_format(before_now(minutes=1)),
|
|
"logentry": {"formatted": "baz"},
|
|
"_meta": {"logentry": {"formatted": {"": {"err": ["some error"]}}}},
|
|
},
|
|
project_id=self.project.id,
|
|
assert_no_errors=False,
|
|
)
|
|
|
|
result = serialize(event)
|
|
assert result["message"] == "baz"
|
|
assert result["_meta"]["message"] == {"": {"err": ["some error"]}}
|
|
|
|
def test_tags_tuples(self):
|
|
event = self.store_event(
|
|
data={
|
|
"event_id": "a" * 32,
|
|
"level": "error", # creates a derived tag.
|
|
"timestamp": iso_format(before_now(minutes=1)),
|
|
"tags": [["foo", "foo"], ["bar", "bar"], ["last", "tag"], None],
|
|
"_meta": {
|
|
"tags": {
|
|
"0": {"1": {"": {"err": ["foo error"]}}},
|
|
"1": {"0": {"": {"err": ["bar error"]}}},
|
|
"3": {"": {"err": ["full error"]}},
|
|
}
|
|
},
|
|
},
|
|
project_id=self.project.id,
|
|
assert_no_errors=False,
|
|
)
|
|
|
|
result = serialize(event)
|
|
# Expect 3 custom tags + derived "level". The ``None``` entry is removed
|
|
# by the serializer as it cannot be rendered. Such entries are generated
|
|
# by Relay normalization.
|
|
assert len(result["tags"]) == 4
|
|
assert result["tags"][0]["value"] == "bar"
|
|
assert result["tags"][1]["value"] == "foo"
|
|
assert result["_meta"]["tags"]["0"]["key"] == {"": {"err": ["bar error"]}}
|
|
assert result["_meta"]["tags"]["1"]["value"] == {"": {"err": ["foo error"]}}
|
|
assert result["_meta"]["tags"].get("2") is None
|
|
|
|
def test_tags_dict(self):
|
|
event = self.store_event(
|
|
data={
|
|
"event_id": "a" * 32,
|
|
"timestamp": iso_format(before_now(minutes=1)),
|
|
"tags": {"foo": "foo", "bar": "bar", "last": "tag"},
|
|
"_meta": {
|
|
"tags": {
|
|
"foo": {"": {"err": ["foo error"]}},
|
|
"bar": {"": {"err": ["bar error"]}},
|
|
}
|
|
},
|
|
},
|
|
project_id=self.project.id,
|
|
assert_no_errors=False,
|
|
)
|
|
|
|
result = serialize(event)
|
|
assert result["tags"][0]["value"] == "bar"
|
|
assert result["tags"][1]["value"] == "foo"
|
|
assert result["_meta"]["tags"]["0"]["value"] == {"": {"err": ["bar error"]}}
|
|
assert result["_meta"]["tags"]["1"]["value"] == {"": {"err": ["foo error"]}}
|
|
assert result["_meta"]["tags"].get("2") is None
|
|
|
|
def test_none_interfaces(self):
|
|
event = self.store_event(
|
|
data={
|
|
"event_id": "a" * 32,
|
|
"timestamp": iso_format(before_now(minutes=1)),
|
|
"breadcrumbs": None,
|
|
"exception": None,
|
|
"logentry": None,
|
|
"request": None,
|
|
"user": None,
|
|
"contexts": None,
|
|
"sdk": None,
|
|
"_meta": None,
|
|
},
|
|
project_id=self.project.id,
|
|
)
|
|
|
|
result = serialize(event)
|
|
assert not any(e["type"] == "breadcrumbs" for e in result["entries"])
|
|
assert not any(e["type"] == "exception" for e in result["entries"])
|
|
assert not any(e["type"] == "message" for e in result["entries"])
|
|
assert not any(e["type"] == "request" for e in result["entries"])
|
|
assert result["user"] is None
|
|
assert result["sdk"] is None
|
|
assert result["contexts"] == {}
|
|
assert "startTimestamp" not in result
|
|
|
|
def test_transaction_event(self):
|
|
event_data = load_data("transaction")
|
|
event = self.store_event(data=event_data, project_id=self.project.id)
|
|
result = serialize(event)
|
|
assert isinstance(result["endTimestamp"], float)
|
|
assert result["endTimestamp"] == event.data.get("timestamp")
|
|
assert isinstance(result["startTimestamp"], float)
|
|
assert result["startTimestamp"] == event.data.get("start_timestamp")
|
|
assert "dateCreated" not in result
|
|
assert "crashFile" not in result
|
|
assert "fingerprints" not in result
|
|
assert "measurements" in result
|
|
assert result["measurements"] == event_data["measurements"]
|
|
assert "breakdowns" in result
|
|
assert result["breakdowns"] == event_data["breakdowns"]
|
|
|
|
def test_transaction_event_with_metrics_summary(self):
|
|
metrics_summary = {
|
|
"d:custom/sentry.event_manager.get_event_instance@second": [
|
|
{
|
|
"min": 10.0,
|
|
"max": 20.0,
|
|
"sum": 30.0,
|
|
"count": 2,
|
|
"tags": {
|
|
"environment": "prod",
|
|
"event_type": "default",
|
|
"release": "backend",
|
|
"result": "success",
|
|
"transaction": "sentry.tasks.store.save_event",
|
|
},
|
|
}
|
|
]
|
|
}
|
|
event_data = load_data("transaction", metrics_summary=metrics_summary)
|
|
event = self.store_event(data=event_data, project_id=self.project.id)
|
|
result = serialize(event)
|
|
assert result["_metrics_summary"] == metrics_summary
|
|
|
|
def test_transaction_event_empty_spans(self):
|
|
event_data = load_data("transaction")
|
|
event_data["spans"] = []
|
|
event = self.store_event(data=event_data, project_id=self.project.id)
|
|
result = serialize(event)
|
|
assert result["entries"][0]["type"] == "spans"
|
|
|
|
def test_event_with_occurrence(self):
|
|
event = self.store_event(
|
|
data={},
|
|
project_id=self.project.id,
|
|
)
|
|
assert event.group is not None
|
|
event_group = event.for_group(event.group)
|
|
event_group.occurrence = occurrence = self.build_occurrence()
|
|
result = serialize(event_group)
|
|
assert result["occurrence"] == convert_dict_key_case(
|
|
occurrence.to_dict(), snake_to_camel_case
|
|
)
|
|
|
|
|
|
@region_silo_test
|
|
class SharedEventSerializerTest(TestCase):
|
|
def test_simple(self):
|
|
event = self.store_event(
|
|
data={"event_id": "a" * 32, "timestamp": iso_format(before_now(minutes=1))},
|
|
project_id=self.project.id,
|
|
)
|
|
|
|
result = serialize(event, None, SharedEventSerializer())
|
|
assert result["id"] == "a" * 32
|
|
assert result["eventID"] == "a" * 32
|
|
assert result.get("context") is None
|
|
assert result.get("contexts") is None
|
|
assert result.get("user") is None
|
|
assert result.get("tags") is None
|
|
assert "sdk" not in result
|
|
assert "errors" not in result
|
|
for entry in result["entries"]:
|
|
assert entry["type"] != "breadcrumbs"
|
|
|
|
|
|
@region_silo_test
|
|
class SimpleEventSerializerTest(TestCase):
|
|
def test_user(self):
|
|
"""
|
|
Use the SimpleEventSerializer to serialize an event
|
|
"""
|
|
event = self.store_event(
|
|
data={
|
|
"event_id": "a" * 32,
|
|
"timestamp": iso_format(before_now(minutes=1)),
|
|
"user": {"email": "[email protected]"},
|
|
},
|
|
project_id=self.project.id,
|
|
)
|
|
assert event.group is not None
|
|
result = serialize(event, None, SimpleEventSerializer())
|
|
|
|
assert result["eventID"] == event.event_id
|
|
assert result["projectID"] == str(event.project_id)
|
|
assert result["groupID"] == str(event.group.id)
|
|
assert result["message"] == event.message
|
|
assert result["title"] == event.title
|
|
assert result["location"] == event.location
|
|
assert result["culprit"] == event.culprit
|
|
assert result["dateCreated"] == event.datetime
|
|
assert result["user"]["id"] == event.get_minimal_user().id
|
|
assert result["user"]["email"] == event.get_minimal_user().email
|
|
assert result["user"]["username"] == event.get_minimal_user().username
|
|
assert result["user"]["ip_address"] == event.get_minimal_user().ip_address
|
|
assert result["tags"] == [
|
|
{"key": "level", "value": "error"},
|
|
{"key": "user", "value": "email:[email protected]", "query": 'user.email:"[email protected]"'},
|
|
]
|
|
|
|
def test_no_group(self):
|
|
"""
|
|
Use the SimpleEventSerializer to serialize an event without group
|
|
"""
|
|
event = self.store_event(
|
|
data={
|
|
"event_id": "a" * 32,
|
|
"start_timestamp": iso_format(before_now(minutes=1, seconds=5)),
|
|
"timestamp": iso_format(before_now(minutes=1)),
|
|
"user": {"email": "[email protected]"},
|
|
"type": "transaction",
|
|
"transaction": "api.issue.delete",
|
|
"spans": [],
|
|
"contexts": {"trace": {"op": "foobar", "trace_id": "a" * 32, "span_id": "a" * 16}},
|
|
},
|
|
project_id=self.project.id,
|
|
)
|
|
|
|
result = serialize(event, None, SimpleEventSerializer())
|
|
assert result["groupID"] is None
|
|
|
|
|
|
@region_silo_test
|
|
class IssueEventSerializerTest(TestCase):
|
|
@mock.patch(
|
|
"sentry.sdk_updates.SdkIndexState",
|
|
return_value=SdkIndexState(sdk_versions={"example.sdk": "2.0.0"}),
|
|
)
|
|
def test_update_on_major(self, mock_index_state):
|
|
min_ago = iso_format(before_now(minutes=1))
|
|
event = self.store_event(
|
|
data={
|
|
"event_id": "a" * 32,
|
|
"message": "oh no",
|
|
"timestamp": min_ago,
|
|
"fingerprint": ["group-1"],
|
|
"sdk": {"name": "example.sdk", "version": "1.0.0"},
|
|
},
|
|
project_id=self.project.id,
|
|
assert_no_errors=False,
|
|
)
|
|
|
|
result = serialize(event, None, IssueEventSerializer())
|
|
assert result["sdkUpdates"] == [
|
|
{
|
|
"enables": [],
|
|
"newSdkVersion": "2.0.0",
|
|
"sdkName": "example.sdk",
|
|
"sdkUrl": None,
|
|
"type": "updateSdk",
|
|
}
|
|
]
|
|
|
|
@mock.patch(
|
|
"sentry.sdk_updates.SdkIndexState",
|
|
return_value=SdkIndexState(sdk_versions={"example.sdk": "1.1.0"}),
|
|
)
|
|
def test_update_on_minor(self, mock_index_state):
|
|
min_ago = iso_format(before_now(minutes=1))
|
|
event = self.store_event(
|
|
data={
|
|
"event_id": "a" * 32,
|
|
"message": "oh no",
|
|
"timestamp": min_ago,
|
|
"fingerprint": ["group-1"],
|
|
"sdk": {"name": "example.sdk", "version": "1.0.0"},
|
|
},
|
|
project_id=self.project.id,
|
|
assert_no_errors=False,
|
|
)
|
|
|
|
result = serialize(event, None, IssueEventSerializer())
|
|
assert result["sdkUpdates"] == [
|
|
{
|
|
"enables": [],
|
|
"newSdkVersion": "1.1.0",
|
|
"sdkName": "example.sdk",
|
|
"sdkUrl": None,
|
|
"type": "updateSdk",
|
|
}
|
|
]
|
|
|
|
@mock.patch(
|
|
"sentry.sdk_updates.SdkIndexState",
|
|
return_value=SdkIndexState(sdk_versions={"example.sdk": "1.0.1"}),
|
|
)
|
|
def test_ignores_patch(self, mock_index_state):
|
|
min_ago = iso_format(before_now(minutes=1))
|
|
event = self.store_event(
|
|
data={
|
|
"event_id": "a" * 32,
|
|
"message": "oh no",
|
|
"timestamp": min_ago,
|
|
"fingerprint": ["group-1"],
|
|
"sdk": {"name": "example.sdk", "version": "1.0.0"},
|
|
},
|
|
project_id=self.project.id,
|
|
assert_no_errors=False,
|
|
)
|
|
|
|
result = serialize(event, None, IssueEventSerializer())
|
|
assert result["sdkUpdates"] == []
|
|
|
|
|
|
@region_silo_test
|
|
class SqlFormatEventSerializerTest(TestCase):
|
|
def test_event_breadcrumb_formatting(self):
|
|
event = self.store_event(
|
|
data={
|
|
"breadcrumbs": [
|
|
{"category": "generic", "message": "should not format this"},
|
|
{
|
|
"category": "query",
|
|
"message": "select * from table where something = $1",
|
|
},
|
|
]
|
|
},
|
|
project_id=self.project.id,
|
|
)
|
|
result = serialize(event, None, SqlFormatEventSerializer())
|
|
|
|
breadcrumb_entry = result["entries"][0]
|
|
breadcrumbs = breadcrumb_entry["data"]["values"]
|
|
|
|
assert breadcrumb_entry["type"] == "breadcrumbs"
|
|
# First breadcrumb should not have a message_formatted property
|
|
assert breadcrumbs[0]["message"] == "should not format this"
|
|
assert "messageRaw" not in breadcrumbs[0]
|
|
assert "messageFormat" not in breadcrumbs[0]
|
|
# Second breadcrumb should have whitespace added
|
|
assert breadcrumbs[1]["message"] == "select *\nfrom table\nwhere something = $1"
|
|
assert breadcrumbs[1]["messageRaw"] == "select * from table where something = $1"
|
|
assert breadcrumbs[1]["messageFormat"] == "sql"
|
|
|
|
def test_event_breadcrumb_formatting_remove_quotes(self):
|
|
event = self.store_event(
|
|
data={
|
|
"breadcrumbs": [
|
|
{
|
|
"category": "query",
|
|
"message": """select "table"."column_name", "table"."column name" from "table" where "something" = $1""",
|
|
},
|
|
{
|
|
"category": "query",
|
|
"message": """This is not "SQL" content.""",
|
|
},
|
|
]
|
|
},
|
|
project_id=self.project.id,
|
|
)
|
|
result = serialize(event, None, SqlFormatEventSerializer())
|
|
|
|
# For breadcrumb 1: should remove quotes from all terms except the one that contains a space ("column name")
|
|
assert (
|
|
result["entries"][0]["data"]["values"][0]["message"]
|
|
== """select table.column_name, table."column name"\nfrom table\nwhere something = $1"""
|
|
)
|
|
|
|
# For breadcrumb 2: Not SQL so shouldn't be changed
|
|
assert (
|
|
result["entries"][0]["data"]["values"][1]["message"] == """This is not "SQL" content."""
|
|
)
|
|
|
|
def test_adds_release_info(self):
|
|
event = self.store_event(
|
|
data={
|
|
"tags": {
|
|
"sentry:release": "[email protected]",
|
|
}
|
|
},
|
|
project_id=self.project.id,
|
|
)
|
|
|
|
repo = self.create_repo(project=self.project, name=self.project.name)
|
|
|
|
release = Release.objects.create(
|
|
version="[email protected]",
|
|
organization=self.organization,
|
|
date_released=datetime(2023, 1, 1),
|
|
)
|
|
release.add_project(self.project)
|
|
release.set_commits(
|
|
[
|
|
{
|
|
"id": "917ac271787e74ff2dbe52b67e77afcff9aaa305",
|
|
"repository": repo.name,
|
|
"author_email": "[email protected]",
|
|
"author_name": "Bob",
|
|
"message": "I hope this fixes it",
|
|
"patch_set": [{"path": "src/sentry/models/release.py", "type": "M"}],
|
|
}
|
|
]
|
|
)
|
|
|
|
result = serialize(event, None, SqlFormatEventSerializer())
|
|
|
|
assert result["release"]["version"] == "[email protected]"
|
|
assert result["release"]["lastCommit"]["id"] == "917ac271787e74ff2dbe52b67e77afcff9aaa305"
|
|
|
|
def test_event_db_span_formatting(self):
|
|
event_data = get_event("n-plus-one-in-django-new-view")
|
|
event_data["contexts"] = {
|
|
"trace": {
|
|
"trace_id": "530c14e044aa464db6ddb43660e6474f",
|
|
"span_id": "139fcdb7c5534eb4",
|
|
}
|
|
}
|
|
event = self.store_event(
|
|
data={
|
|
"type": "transaction",
|
|
"transaction": "/organizations/:orgId/performance/:eventSlug/",
|
|
"start_timestamp": iso_format(before_now(minutes=1, milliseconds=500)),
|
|
"timestamp": iso_format(before_now(minutes=1)),
|
|
"contexts": {
|
|
"trace": {
|
|
"trace_id": "ff62a8b040f340bda5d830223def1d81",
|
|
"span_id": "8f5a2b8768cafb4e",
|
|
"type": "trace",
|
|
}
|
|
},
|
|
"spans": [
|
|
{
|
|
"description": """select "table"."column_name", "table"."column name" from "table" where "something" = $1""",
|
|
"op": "db",
|
|
"parent_span_id": "abe79ad9292b90a9",
|
|
"span_id": "9c045ea336297177",
|
|
"start_timestamp": timestamp_format(
|
|
before_now(minutes=1, milliseconds=200)
|
|
),
|
|
"timestamp": timestamp_format(before_now(minutes=1)),
|
|
"trace_id": "ff62a8b040f340bda5d830223def1d81",
|
|
},
|
|
{
|
|
"description": "http span",
|
|
"op": "http",
|
|
"parent_span_id": "a99fd04e79e17631",
|
|
"span_id": "abe79ad9292b90a9",
|
|
"start_timestamp": timestamp_format(
|
|
before_now(minutes=1, milliseconds=200)
|
|
),
|
|
"timestamp": timestamp_format(before_now(minutes=1)),
|
|
"trace_id": "ff62a8b040f340bda5d830223def1d81",
|
|
},
|
|
],
|
|
},
|
|
project_id=self.project.id,
|
|
)
|
|
result = serialize(event, None, SqlFormatEventSerializer())
|
|
|
|
# For span 1: Should remove quotes from all terms except the one that contains a space ("column name")
|
|
assert (
|
|
result["entries"][0]["data"][0]["description"]
|
|
== """select table.column_name, table."column name"\nfrom table\nwhere something = $1"""
|
|
)
|
|
|
|
# For span 2: Not a db span so no change
|
|
assert result["entries"][0]["data"][1]["description"] == """http span"""
|
|
|
|
def test_db_formatting_perf_optimizations(self):
|
|
SQL_QUERY_OK = """select * from table where something in (%s, %s, %s)"""
|
|
SQL_QUERY_TOO_LARGE = "a" * 1501
|
|
|
|
event = self.store_event(
|
|
data={
|
|
"breadcrumbs": [
|
|
{
|
|
"category": "query",
|
|
"message": SQL_QUERY_OK,
|
|
},
|
|
{
|
|
"category": "query",
|
|
"message": SQL_QUERY_OK,
|
|
},
|
|
{
|
|
"category": "query",
|
|
"message": SQL_QUERY_TOO_LARGE,
|
|
},
|
|
]
|
|
+ [{"category": "query", "message": str(i)} for i in range(0, 30)]
|
|
},
|
|
project_id=self.project.id,
|
|
)
|
|
|
|
with mock.patch("sqlparse.format", return_value="") as mock_format:
|
|
serialize(event, None, SqlFormatEventSerializer())
|
|
|
|
assert (
|
|
len(
|
|
list(
|
|
filter(
|
|
lambda args: SQL_QUERY_OK in args[0],
|
|
mock_format.call_args_list,
|
|
)
|
|
)
|
|
)
|
|
== 1
|
|
), "SQL_QUERY_OK should have been formatted a single time"
|
|
|
|
assert not any(
|
|
SQL_QUERY_TOO_LARGE in args[0] for args in mock_format.call_args_list
|
|
), "SQL_QUERY_TOO_LARGE should not have been formatted"
|
|
|
|
assert mock_format.call_count == 20, "Format should have been called 20 times"
|