Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ref(flags): allow launchdarkly member to be null #83054

Merged
merged 8 commits into from
Jan 8, 2025
Merged
18 changes: 9 additions & 9 deletions src/sentry/flags/docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,15 @@ Retrieve a collection of flag logs.

**Attributes**

| Column | Type | Description |
| ------------- | ------ | ------------------------------------------------------------- |
| action | string | Enum of `created`, `updated`, or `deleted`. |
| createdAt | string | ISO-8601 timestamp of when the flag was changed. |
| createdBy | string | The user responsible for the change. |
| createdByType | string | Enum of `email`, `id`, or `name`. |
| flag | string | The name of the flag changed. Maps to flag_log_id in the URI. |
| id | number | A unique identifier for the log entry. |
| tags | object | A collection of provider-specified scoping metadata. |
| Column | Type | Description |
| ------------- | ---------------- | ------------------------------------------------------------- |
| action | string | Enum of `created`, `updated`, or `deleted`. |
| createdAt | string | ISO-8601 timestamp of when the flag was changed. |
| createdBy | optional[string] | The user responsible for the change. |
| createdByType | optional[string] | Enum of `email`, `id`, or `name`. |
| flag | string | The name of the flag changed. Maps to flag_log_id in the URI. |
| id | number | A unique identifier for the log entry. |
| tags | object | A collection of provider-specified scoping metadata. |

- Response 200

Expand Down
10 changes: 7 additions & 3 deletions src/sentry/flags/endpoints/logs.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ class FlagAuditLogModelSerializerResponse(TypedDict):
id: int
action: str
createdAt: datetime
createdBy: str
createdByType: str
createdBy: str | None
createdByType: str | None
flag: str
tags: dict[str, Any]

Expand All @@ -35,7 +35,11 @@ def serialize(self, obj, attrs, user, **kwargs) -> FlagAuditLogModelSerializerRe
"action": ActionEnum.to_string(obj.action),
"createdAt": obj.created_at.isoformat(),
"createdBy": obj.created_by,
"createdByType": CreatedByTypeEnum.to_string(obj.created_by_type),
"createdByType": (
None
if obj.created_by_type is None
else CreatedByTypeEnum.to_string(obj.created_by_type)
),
"flag": obj.flag,
"tags": obj.tags,
}
Expand Down
38 changes: 38 additions & 0 deletions src/sentry/flags/migrations/0003_allow_null_created_by.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Generated by Django 5.1.4 on 2025-01-07 21:56

from django.db import migrations, models

from sentry.new_migrations.migrations import CheckedMigration


class Migration(CheckedMigration):
# This flag is used to mark that a migration shouldn't be automatically run in production.
# This should only be used for operations where it's safe to run the migration after your
# code has deployed. So this should not be used for most operations that alter the schema
# of a table.
# Here are some things that make sense to mark as post deployment:
# - Large data migrations. Typically we want these to be run manually so that they can be
# monitored and not block the deploy for a long period of time while they run.
# - Adding indexes to large tables. Since this can take a long time, we'd generally prefer to
# run this outside deployments so that we don't block them. Note that while adding an index
# is a schema change, it's completely safe to run the operation after the code has deployed.
# Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment

is_post_deployment = False

dependencies = [
("flags", "0002_add_flags_webhooksigningsecret"),
]

operations = [
migrations.AlterField(
model_name="flagauditlogmodel",
name="created_by",
field=models.CharField(max_length=100, null=True),
),
migrations.AlterField(
model_name="flagauditlogmodel",
name="created_by_type",
field=models.PositiveSmallIntegerField(null=True),
),
]
4 changes: 2 additions & 2 deletions src/sentry/flags/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ class FlagAuditLogModel(Model):

action = models.PositiveSmallIntegerField(choices=ACTION_TYPES)
created_at = models.DateTimeField(default=timezone.now)
created_by = models.CharField(max_length=100)
created_by_type = models.PositiveSmallIntegerField(choices=CREATED_BY_TYPE_TYPES)
created_by = models.CharField(max_length=100, null=True)
created_by_type = models.PositiveSmallIntegerField(choices=CREATED_BY_TYPE_TYPES, null=True)
flag = models.CharField(max_length=100)
organization_id = HybridCloudForeignKey("sentry.Organization", null=False, on_delete="CASCADE")
tags = models.JSONField()
Expand Down
15 changes: 10 additions & 5 deletions src/sentry/flags/providers.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ class FlagAuditLogRow(TypedDict):

action: int
created_at: datetime.datetime
created_by: str
created_by_type: int
created_by: str | None
created_by_type: int | None
flag: str
organization_id: int
tags: dict[str, Any]
Expand Down Expand Up @@ -92,7 +92,7 @@ def get_provider(
class LaunchDarklyItemSerializer(serializers.Serializer):
accesses = serializers.ListField(required=True)
date = serializers.IntegerField(required=True)
member = serializers.DictField(required=True)
member = serializers.DictField(required=False, allow_null=True)
name = serializers.CharField(max_length=100, required=True)
description = serializers.CharField(allow_blank=True, required=True)

Expand Down Expand Up @@ -136,14 +136,19 @@ def handle(self, message: dict[str, Any]) -> list[FlagAuditLogRow]:
if access["action"] not in SUPPORTED_LAUNCHDARKLY_ACTIONS:
return []

if result.get("member"):
created_by = result["member"]["email"]
else:
created_by = None

return [
{
"action": _handle_launchdarkly_actions(access["action"]),
"created_at": datetime.datetime.fromtimestamp(
result["date"] / 1000.0, datetime.UTC
),
"created_by": result["member"]["email"],
"created_by_type": CREATED_BY_TYPE_MAP["email"],
"created_by": created_by,
"created_by_type": CREATED_BY_TYPE_MAP["email"] if created_by else None,
"flag": result["name"],
"organization_id": self.organization_id,
"tags": {"description": result["description"]},
Expand Down
25 changes: 25 additions & 0 deletions tests/sentry/flags/endpoints/test_logs.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,31 @@ def test_get(self):
assert result["data"][0]["flag"] == "hello"
assert result["data"][0]["tags"] == {"commit_sha": "123"}

def test_get_no_created_by(self):
model = FlagAuditLogModel(
action=0,
created_at=datetime.now(timezone.utc),
created_by=None,
created_by_type=None,
flag="hello",
organization_id=self.organization.id,
tags={"commit_sha": "123"},
)
model.save()

with self.feature(self.features):
response = self.client.get(self.url)
assert response.status_code == 200

result = response.json()
assert len(result["data"]) == 1
assert result["data"][0]["action"] == "created"
assert "createdAt" in result["data"][0]
assert result["data"][0]["createdBy"] is None
assert result["data"][0]["createdByType"] is None
assert result["data"][0]["flag"] == "hello"
assert result["data"][0]["tags"] == {"commit_sha": "123"}

def test_get_filter_by_flag(self):
FlagAuditLogModel(
action=0,
Expand Down
134 changes: 134 additions & 0 deletions tests/sentry/flags/providers/test_launchdarkly.py
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,140 @@ def test_launchdarkly_update():
assert flag_row["action"] == ACTION_MAP["updated"]


def test_launchdarkly_create_no_member():
request_data = {
"_links": {
"canonical": {
"href": "/api/v2/flags/default/test-flag",
"type": "application/json",
},
"parent": {"href": "/api/v2/auditlog", "type": "application/json"},
"self": {
"href": "/api/v2/auditlog/1234",
"type": "application/json",
},
"site": {"href": "/default/~/features/test-flag", "type": "text/html"},
},
"_id": "1234",
"_accountId": "1234",
"date": 1729123465221,
"accesses": [
{"action": "createFlag", "resource": "proj/default:env/test:flag/test-flag"},
],
"kind": "flag",
"name": "test flag",
"description": "flag was created",
"shortDescription": "",
"titleVerb": "created the flag",
"title": "Michelle created the flag [test flag](https://app.launchdarkly.com/default/~/features/test-flag)",
"target": {
"_links": {
"canonical": {
"href": "/api/v2/flags/default/test-flag",
"type": "application/json",
},
"site": {"href": "/default/~/features/test-flag", "type": "text/html"},
},
"name": "test flag",
"resources": [
"proj/default:env/test:flag/test-flag",
"proj/default:env/production:flag/test-flag",
],
},
"currentVersion": {
"name": "test flag",
"kind": "boolean",
"description": "testing a feature flag",
"key": "test-flag",
"_version": 1,
"creationDate": 1729123465176,
"includeInSnippet": False,
"clientSideAvailability": {"usingMobileKey": False, "usingEnvironmentId": False},
"variations": [
{"_id": "d883033e-fa8b-41d4-a4be-112d9a59278e", "value": True, "name": "on"},
{"_id": "73aaa33f-c9ca-4bdc-8c97-01a20567aa3f", "value": False, "name": "off"},
],
"temporary": False,
"tags": [],
"_links": {
"parent": {"href": "/api/v2/flags/default", "type": "application/json"},
"self": {"href": "/api/v2/flags/default/test-flag", "type": "application/json"},
},
"maintainerId": "1234",
"_maintainer": {
"_links": {
"self": {
"href": "/api/v2/members/1234",
"type": "application/json",
}
},
"_id": "1234",
"firstName": "Michelle",
"lastName": "Doe",
"role": "owner",
"email": "[email protected]",
},
"goalIds": [],
"experiments": {"baselineIdx": 0, "items": []},
"customProperties": {},
"archived": False,
"deprecated": False,
"defaults": {"onVariation": 0, "offVariation": 1},
"environments": {
"production": {
"on": False,
"archived": False,
"salt": "1234",
"sel": "1234",
"lastModified": 1729123465190,
"version": 1,
"targets": [],
"contextTargets": [],
"rules": [],
"fallthrough": {"variation": 0},
"offVariation": 1,
"prerequisites": [],
"_site": {
"href": "/default/production/features/test-flag",
"type": "text/html",
},
"_environmentName": "Production",
"trackEvents": False,
"trackEventsFallthrough": False,
},
"test": {
"on": False,
"archived": False,
"salt": "7495d3dcf72f43aaa075012fad947d0d",
"sel": "61b4861e6ed54135bc244bb120e9e2da",
"lastModified": 1729123465190,
"version": 1,
"targets": [],
"contextTargets": [],
"rules": [],
"fallthrough": {"variation": 0},
"offVariation": 1,
"prerequisites": [],
"_site": {"href": "/default/test/features/test-flag", "type": "text/html"},
"_environmentName": "Test",
"trackEvents": False,
"trackEventsFallthrough": False,
},
},
},
}
res = LaunchDarklyProvider(123, None).handle(request_data)
assert len(res) == 1
flag_row = res[0]
assert flag_row["action"] == ACTION_MAP["created"]
assert flag_row["flag"] == "test flag"
assert flag_row["created_by"] is None
assert flag_row["created_by_type"] is None
assert flag_row["organization_id"] == 123
assert flag_row["tags"] is not None
assert flag_row["tags"]["description"] == "flag was created"


def test_launchdarkly_delete_and_update():
request_data = {
"_id": "1234",
Expand Down
Loading