From 5405cc59b4e49a8936a278439c018072fcdc7df4 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Tue, 10 Dec 2024 18:01:28 +0000 Subject: [PATCH] feat: Heal all identities with blank traits (#4908) --- .../commands/ensure_identity_traits_blanks.py | 65 ++++++++ api/environments/dynamodb/wrappers/base.py | 10 +- api/poetry.lock | 101 +++++++----- api/pyproject.toml | 4 +- .../edge_api/test_unit_edge_api_commands.py | 148 +++++++++++++++++- 5 files changed, 286 insertions(+), 42 deletions(-) create mode 100644 api/edge_api/management/commands/ensure_identity_traits_blanks.py diff --git a/api/edge_api/management/commands/ensure_identity_traits_blanks.py b/api/edge_api/management/commands/ensure_identity_traits_blanks.py new file mode 100644 index 000000000000..53eabc3d1345 --- /dev/null +++ b/api/edge_api/management/commands/ensure_identity_traits_blanks.py @@ -0,0 +1,65 @@ +from typing import Any + +from django.core.management import BaseCommand +from structlog import get_logger +from structlog.stdlib import BoundLogger + +from environments.dynamodb import DynamoIdentityWrapper + +identity_wrapper = DynamoIdentityWrapper() + + +LOG_COUNT_EVERY = 100_000 + + +class Command(BaseCommand): + def handle(self, *args: Any, **options: Any) -> None: + total_count = identity_wrapper.table.item_count + scanned_count = 0 + fixed_count = 0 + + log: BoundLogger = get_logger(total_count=total_count) + log.info("started") + + for identity_document in identity_wrapper.query_get_all_items(): + should_write_identity_document = False + + if identity_traits_data := identity_document.get("identity_traits"): + blank_traits = ( + trait_data + for trait_data in identity_traits_data + if "trait_value" not in trait_data + ) + for trait_data in blank_traits: + should_write_identity_document = True + trait_data["trait_value"] = "" + + scanned_count += 1 + scanned_percentage = scanned_count / total_count * 100 + + if should_write_identity_document: + identity_wrapper.put_item(identity_document) + fixed_count += 1 + + log.info( + "identity-fixed", + identity_uuid=identity_document["identity_uuid"], + scanned_count=scanned_count, + scanned_percentage=scanned_percentage, + fixed_count=fixed_count, + ) + + if not (scanned_count % LOG_COUNT_EVERY): + log.info( + "in-progress", + scanned_count=scanned_count, + scanned_percentage=scanned_percentage, + fixed_count=fixed_count, + ) + + log.info( + "finished", + scanned_count=scanned_count, + scanned_percentage=scanned_percentage, + fixed_count=fixed_count, + ) diff --git a/api/environments/dynamodb/wrappers/base.py b/api/environments/dynamodb/wrappers/base.py index abac7ca9b442..5f0d545be422 100644 --- a/api/environments/dynamodb/wrappers/base.py +++ b/api/environments/dynamodb/wrappers/base.py @@ -1,4 +1,5 @@ import typing +from functools import partial import boto3 from botocore.config import Config @@ -33,8 +34,13 @@ def is_enabled(self) -> bool: return self.table is not None def query_get_all_items(self, **kwargs: dict) -> typing.Generator[dict, None, None]: + if kwargs: + response_getter = partial(self.table.query, **kwargs) + else: + response_getter = partial(self.table.scan) + while True: - query_response = self.table.query(**kwargs) + query_response = response_getter() for item in query_response["Items"]: yield item @@ -42,4 +48,4 @@ def query_get_all_items(self, **kwargs: dict) -> typing.Generator[dict, None, No if not last_evaluated_key: break - kwargs["ExclusiveStartKey"] = last_evaluated_key + response_getter.keywords["ExclusiveStartKey"] = last_evaluated_key diff --git a/api/poetry.lock b/api/poetry.lock index a1635c1c4310..85b3b132af1a 100644 --- a/api/poetry.lock +++ b/api/poetry.lock @@ -337,13 +337,13 @@ pycparser = "*" [[package]] name = "cfgv" -version = "3.3.1" +version = "3.4.0" description = "Validate configuration and produce human readable error messages." optional = false -python-versions = ">=3.6.1" +python-versions = ">=3.8" files = [ - {file = "cfgv-3.3.1-py2.py3-none-any.whl", hash = "sha256:c6a0883f3917a037485059700b9e75da2464e6c27051014ad85ba6aaa5884426"}, - {file = "cfgv-3.3.1.tar.gz", hash = "sha256:f5a830efb9ce7a445376bb66ec94c638a9787422f96264c98edc6bdeed8ab736"}, + {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, + {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, ] [[package]] @@ -702,13 +702,13 @@ profile = ["gprof2dot (>=2022.7.29)"] [[package]] name = "distlib" -version = "0.3.7" +version = "0.3.9" description = "Distribution utilities" optional = false python-versions = "*" files = [ - {file = "distlib-0.3.7-py2.py3-none-any.whl", hash = "sha256:2e24928bc811348f0feb63014e97aaae3037f2cf48712d51ae61df7fd6075057"}, - {file = "distlib-0.3.7.tar.gz", hash = "sha256:9dafe54b34a028eafd95039d5e5d4851a13734540f1331060d31c9916e7147a8"}, + {file = "distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87"}, + {file = "distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403"}, ] [[package]] @@ -1295,18 +1295,19 @@ pyrepl = ">=0.8.2" [[package]] name = "filelock" -version = "3.12.2" +version = "3.16.1" description = "A platform independent file lock." optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "filelock-3.12.2-py3-none-any.whl", hash = "sha256:cbb791cdea2a72f23da6ac5b5269ab0a0d161e9ef0100e653b69049a7706d1ec"}, - {file = "filelock-3.12.2.tar.gz", hash = "sha256:002740518d8aa59a26b0c76e10fb8c6e15eae825d34b6fdf670333fd7b938d81"}, + {file = "filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0"}, + {file = "filelock-3.16.1.tar.gz", hash = "sha256:c249fbfcd5db47e5e2d6d62198e565475ee65e4831e2561c8e313fa7eb961435"}, ] [package.extras] -docs = ["furo (>=2023.5.20)", "sphinx (>=7.0.1)", "sphinx-autodoc-typehints (>=1.23,!=1.23.4)"] -testing = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "diff-cover (>=7.5)", "pytest (>=7.3.1)", "pytest-cov (>=4.1)", "pytest-mock (>=3.10)", "pytest-timeout (>=2.1)"] +docs = ["furo (>=2024.8.6)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4.1)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.6.1)", "diff-cover (>=9.2)", "pytest (>=8.3.3)", "pytest-asyncio (>=0.24)", "pytest-cov (>=5)", "pytest-mock (>=3.14)", "pytest-timeout (>=2.3.1)", "virtualenv (>=20.26.4)"] +typing = ["typing-extensions (>=4.12.2)"] [[package]] name = "flagsmith" @@ -1918,13 +1919,13 @@ dev = ["black", "pytest"] [[package]] name = "identify" -version = "2.5.26" +version = "2.6.3" description = "File identification library for Python" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "identify-2.5.26-py2.py3-none-any.whl", hash = "sha256:c22a8ead0d4ca11f1edd6c9418c3220669b3b7533ada0a0ffa6cc0ef85cf9b54"}, - {file = "identify-2.5.26.tar.gz", hash = "sha256:7243800bce2f58404ed41b7c002e53d4d22bcf3ae1b7900c2d7aefd95394bf7f"}, + {file = "identify-2.6.3-py2.py3-none-any.whl", hash = "sha256:9edba65473324c2ea9684b1f944fe3191db3345e50b6d04571d10ed164f8d7bd"}, + {file = "identify-2.6.3.tar.gz", hash = "sha256:62f5dae9b5fef52c84cc188514e9ea4f3f636b1d8799ab5ebc475471f9e47a02"}, ] [package.extras] @@ -2342,18 +2343,15 @@ files = [ [[package]] name = "nodeenv" -version = "1.8.0" +version = "1.9.1" description = "Node.js virtual environment builder" optional = false -python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ - {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, - {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, + {file = "nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9"}, + {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, ] -[package.dependencies] -setuptools = "*" - [[package]] name = "oauth2client" version = "4.1.3" @@ -2551,13 +2549,13 @@ tests = ["pytest (>=5.4.1)", "pytest-cov (>=2.8.1)", "pytest-mypy (>=0.8.0)", "p [[package]] name = "pre-commit" -version = "3.0.4" +version = "4.0.1" description = "A framework for managing and maintaining multi-language pre-commit hooks." optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "pre_commit-3.0.4-py2.py3-none-any.whl", hash = "sha256:9e3255edb0c9e7fe9b4f328cb3dc86069f8fdc38026f1bf521018a05eaf4d67b"}, - {file = "pre_commit-3.0.4.tar.gz", hash = "sha256:bc4687478d55578c4ac37272fe96df66f73d9b5cf81be6f28627d4e712e752d5"}, + {file = "pre_commit-4.0.1-py2.py3-none-any.whl", hash = "sha256:efde913840816312445dc98787724647c65473daefe420785f885e8ed9a06878"}, + {file = "pre_commit-4.0.1.tar.gz", hash = "sha256:80905ac375958c0444c65e9cebebd948b3cdb518f335a091a670a89d652139d2"}, ] [package.dependencies] @@ -3193,6 +3191,21 @@ pytest = ">=5.0" [package.extras] dev = ["pre-commit", "pytest-asyncio", "tox"] +[[package]] +name = "pytest-structlog" +version = "1.1" +description = "Structured logging assertions" +optional = false +python-versions = ">=3.8" +files = [ + {file = "pytest_structlog-1.1-py3-none-any.whl", hash = "sha256:928475948f886bc027f1550dccb892fb46bfb8bb1ddb0c44ab4fa3c5b3f3079a"}, + {file = "pytest_structlog-1.1.tar.gz", hash = "sha256:440123b0a7f93482383995b3fb796ed8c1b9a37edf405683a34774993ef09543"}, +] + +[package.dependencies] +pytest = "*" +structlog = ">=22.2.0" + [[package]] name = "pytest-xdist" version = "3.6.1" @@ -3340,7 +3353,6 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, - {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, @@ -3874,6 +3886,23 @@ files = [ {file = "sseclient_py-1.8.0-py2.py3-none-any.whl", hash = "sha256:4ecca6dc0b9f963f8384e9d7fd529bf93dd7d708144c4fb5da0e0a1a926fee83"}, ] +[[package]] +name = "structlog" +version = "24.4.0" +description = "Structured Logging for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "structlog-24.4.0-py3-none-any.whl", hash = "sha256:597f61e80a91cc0749a9fd2a098ed76715a1c8a01f73e336b746504d1aad7610"}, + {file = "structlog-24.4.0.tar.gz", hash = "sha256:b27bfecede327a6d2da5fbc96bd859f114ecc398a6389d664f62085ee7ae6fc4"}, +] + +[package.extras] +dev = ["freezegun (>=0.2.8)", "mypy (>=1.4)", "pretend", "pytest (>=6.0)", "pytest-asyncio (>=0.17)", "rich", "simplejson", "twisted"] +docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-mermaid", "sphinxext-opengraph", "twisted"] +tests = ["freezegun (>=0.2.8)", "pretend", "pytest (>=6.0)", "pytest-asyncio (>=0.17)", "simplejson"] +typing = ["mypy (>=1.4)", "rich", "twisted"] + [[package]] name = "toml" version = "0.10.2" @@ -3969,22 +3998,22 @@ files = [ [[package]] name = "virtualenv" -version = "20.24.2" +version = "20.28.0" description = "Virtual Python Environment builder" optional = false -python-versions = ">=3.7" +python-versions = ">=3.8" files = [ - {file = "virtualenv-20.24.2-py3-none-any.whl", hash = "sha256:43a3052be36080548bdee0b42919c88072037d50d56c28bd3f853cbe92b953ff"}, - {file = "virtualenv-20.24.2.tar.gz", hash = "sha256:fd8a78f46f6b99a67b7ec5cf73f92357891a7b3a40fd97637c27f854aae3b9e0"}, + {file = "virtualenv-20.28.0-py3-none-any.whl", hash = "sha256:23eae1b4516ecd610481eda647f3a7c09aea295055337331bb4e6892ecce47b0"}, + {file = "virtualenv-20.28.0.tar.gz", hash = "sha256:2c9c3262bb8e7b87ea801d715fae4495e6032450c71d2309be9550e7364049aa"}, ] [package.dependencies] distlib = ">=0.3.7,<1" filelock = ">=3.12.2,<4" -platformdirs = ">=3.9.1,<4" +platformdirs = ">=3.9.1,<5" [package.extras] -docs = ["furo (>=2023.5.20)", "proselint (>=0.13)", "sphinx (>=7.0.1)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] [[package]] @@ -4187,4 +4216,4 @@ files = [ [metadata] lock-version = "2.0" python-versions = ">=3.11, <3.13" -content-hash = "03dcc629288ed09eca14b7be8c902a801c68011a5aaa12d65980bc5c20e4f24b" +content-hash = "3a490ddf78d714b0fb4394a3f384856576d1db5b5c9ad270a29d1434aa2562f6" diff --git a/api/pyproject.toml b/api/pyproject.toml index 24c9cb4b0da1..c808f8b6f967 100644 --- a/api/pyproject.toml +++ b/api/pyproject.toml @@ -173,6 +173,7 @@ flagsmith-task-processor = { git = "https://github.com/Flagsmith/flagsmith-task- flagsmith-common = { git = "https://github.com/Flagsmith/flagsmith-common", tag = "v1.4.2" } tzdata = "^2024.1" djangorestframework-simplejwt = "^5.3.1" +structlog = "^24.4.0" [tool.poetry.group.auth-controller] optional = true @@ -201,7 +202,7 @@ workflows-logic = { git = "https://github.com/flagsmith/flagsmith-workflows", ta [tool.poetry.group.dev.dependencies] django-test-migrations = "~1.2.0" responses = "~0.22.0" -pre-commit = "~3.0.4" +pre-commit = "^4.0.1" pytest-mock = "~3.10.0" pytest-lazy-fixture = "~0.6.3" moto = "~4.1.3" @@ -219,6 +220,7 @@ requests-mock = "^1.11.0" django-extensions = "^3.2.3" pdbpp = "^0.10.3" mypy-boto3-dynamodb = "^1.33.0" +pytest-structlog = "^1.1" [build-system] requires = ["poetry-core>=1.5.0"] diff --git a/api/tests/unit/edge_api/test_unit_edge_api_commands.py b/api/tests/unit/edge_api/test_unit_edge_api_commands.py index 7caac1724ef5..347eee434dcb 100644 --- a/api/tests/unit/edge_api/test_unit_edge_api_commands.py +++ b/api/tests/unit/edge_api/test_unit_edge_api_commands.py @@ -1,11 +1,21 @@ +import typing +import uuid + from django.core.management import call_command -from pytest_mock import MockerFixture +from edge_api.management.commands.ensure_identity_traits_blanks import ( + identity_wrapper, +) from projects.models import EdgeV2MigrationStatus, Project +if typing.TYPE_CHECKING: + from mypy_boto3_dynamodb.service_resource import Table + from pytest_mock import MockerFixture + from pytest_structlog import StructuredLogCapture + def test_migrate_to_edge_v2__new_projects__dont_migrate( - mocker: MockerFixture, project: Project + mocker: "MockerFixture", project: Project ) -> None: # Given # unmigrated projects are present @@ -44,7 +54,7 @@ def test_migrate_to_edge_v2__new_projects__dont_migrate( def test_migrate_to_edge_v2__core_projects__dont_migrate( - mocker: MockerFixture, project: Project + mocker: "MockerFixture", project: Project ) -> None: # Given # unmigrated Core projects are present @@ -76,3 +86,135 @@ def test_migrate_to_edge_v2__core_projects__dont_migrate( # Then # unmigrated Core projects were not migrated migrate_project_environments_to_v2_mock.assert_not_called() + + +def test_ensure_identity_traits_blanks__calls_expected( + flagsmith_identities_table: "Table", + mocker: "MockerFixture", +) -> None: + # Given + environment_api_key = "test" + identity_without_traits = { + "composite_key": f"{environment_api_key}_identity_without_traits", + "environment_api_key": environment_api_key, + "identifier": "identity_without_traits", + "identity_uuid": "8208c268-e286-4bff-848a-e4b97032fca9", + } + identity_with_correct_traits = { + "composite_key": f"{environment_api_key}_identity_with_correct_traits", + "identifier": "identity_with_correct_traits", + "environment_api_key": environment_api_key, + "identity_uuid": "1a47c1e2-4a9d-4f45-840e-a4cf1a23329e", + "identity_traits": [{"trait_key": "key", "trait_value": "value"}], + } + identity_with_skipped_blank_trait_value = { + "composite_key": f"{environment_api_key}_identity_with_skipped_blank_trait_value", + "identifier": "identity_with_skipped_blank_trait_value", + "environment_api_key": environment_api_key, + "identity_uuid": "33e11400-3a34-4b09-9541-3c99e9bf713a", + "identity_traits": [ + {"trait_key": "key", "trait_value": "value"}, + {"trait_key": "blank"}, + ], + } + fixed_identity_with_skipped_blank_trait_value = { + **identity_with_skipped_blank_trait_value, + "identity_traits": [ + {"trait_key": "key", "trait_value": "value"}, + {"trait_key": "blank", "trait_value": ""}, + ], + } + + flagsmith_identities_table.put_item(Item=identity_without_traits) + flagsmith_identities_table.put_item(Item=identity_with_correct_traits) + flagsmith_identities_table.put_item(Item=identity_with_skipped_blank_trait_value) + + identity_wrapper_put_item_mock = mocker.patch( + "edge_api.management.commands.ensure_identity_traits_blanks.identity_wrapper.put_item", + side_effect=identity_wrapper.put_item, + ) + + # When + call_command("ensure_identity_traits_blanks") + + # Then + assert flagsmith_identities_table.scan()["Items"] == [ + identity_without_traits, + identity_with_correct_traits, + fixed_identity_with_skipped_blank_trait_value, + ] + identity_wrapper_put_item_mock.assert_called_once_with( + fixed_identity_with_skipped_blank_trait_value, + ) + + +def test_ensure_identity_traits_blanks__logs_expected( + flagsmith_identities_table: "Table", + log: "StructuredLogCapture", + mocker: "MockerFixture", +) -> None: + # Given + environment_api_key = "test" + expected_log_count_every = 10 + mocker.patch( + "edge_api.management.commands.ensure_identity_traits_blanks.LOG_COUNT_EVERY", + new=expected_log_count_every, + ) + identity_with_skipped_blank_trait_value = { + "composite_key": f"{environment_api_key}_identity_with_skipped_blank_trait_value", + "identifier": "identity_with_skipped_blank_trait_value", + "environment_api_key": environment_api_key, + "identity_uuid": "33e11400-3a34-4b09-9541-3c99e9bf713a", + "identity_traits": [ + {"trait_key": "key", "trait_value": "value"}, + {"trait_key": "blank"}, + ], + } + + for i in range(expected_log_count_every): + flagsmith_identities_table.put_item( + Item={ + "composite_key": f"{environment_api_key}_identity_without_traits_{i}", + "identifier": f"identity_without_traits_{i}", + "environment_api_key": environment_api_key, + "identity_uuid": str(uuid.uuid4()), + } + ) + flagsmith_identities_table.put_item(Item=identity_with_skipped_blank_trait_value) + + # When + call_command("ensure_identity_traits_blanks") + + # Then + assert log.events == [ + { + "event": "started", + "level": "info", + "total_count": 11, + }, + { + "event": "in-progress", + "fixed_count": 0, + "level": "info", + "scanned_count": 10, + "scanned_percentage": 90.9090909090909, + "total_count": 11, + }, + { + "event": "identity-fixed", + "fixed_count": 1, + "identity_uuid": "33e11400-3a34-4b09-9541-3c99e9bf713a", + "level": "info", + "scanned_count": 11, + "scanned_percentage": 100.0, + "total_count": 11, + }, + { + "event": "finished", + "fixed_count": 1, + "level": "info", + "scanned_count": 11, + "scanned_percentage": 100.0, + "total_count": 11, + }, + ]