diff --git a/.azure/infrastructure/staging.bicepparam b/.azure/infrastructure/staging.bicepparam index 83ad22b43..c434a5469 100644 --- a/.azure/infrastructure/staging.bicepparam +++ b/.azure/infrastructure/staging.bicepparam @@ -31,15 +31,15 @@ param slackNotifierSku = { } param postgresConfiguration = { sku: { - name: 'Standard_B1ms' - tier: 'Burstable' + name: 'Standard_D4ads_v5' + tier: 'GeneralPurpose' } storage: { - storageSizeGB: 32 + storageSizeGB: 256 autoGrow: 'Enabled' type: 'Premium_LRS' } - enableIndexTuning: false + enableIndexTuning: true enableQueryPerformanceInsight: true } diff --git a/.azure/modules/redis/main.bicep b/.azure/modules/redis/main.bicep index d6e873b18..80aa92885 100644 --- a/.azure/modules/redis/main.bicep +++ b/.azure/modules/redis/main.bicep @@ -97,9 +97,6 @@ module privateDnsZone '../privateDnsZone/main.bicep' = { module privateDnsZoneGroup '../privateDnsZoneGroup/main.bicep' = { name: '${namePrefix}-redis-privateDnsZoneGroup' - dependsOn: [ - privateDnsZone - ] params: { name: 'default' dnsZoneGroupName: 'privatelink-redis-cache-windows-net' diff --git a/.azure/modules/serviceBus/main.bicep b/.azure/modules/serviceBus/main.bicep index 4c34d3a56..50ad8883b 100644 --- a/.azure/modules/serviceBus/main.bicep +++ b/.azure/modules/serviceBus/main.bicep @@ -83,9 +83,6 @@ module privateDnsZone '../privateDnsZone/main.bicep' = { module privateDnsZoneGroup '../privateDnsZoneGroup/main.bicep' = { name: '${namePrefix}-service-bus-privateDnsZoneGroup' - dependsOn: [ - privateDnsZone - ] params: { name: 'default' dnsZoneGroupName: 'privatelink-servicebus-windows-net' diff --git a/.env b/.env index cbd2e3101..2309e6938 100644 --- a/.env +++ b/.env @@ -1,7 +1,12 @@ # ENV variables for docker-compose POSTGRES_USER=postgres POSTGRES_PASSWORD=supersecret -POSTGRES_DB=Dialogporten +POSTGRES_DB=dialogporten DB_CONNECTION_STRING=Server=dialogporten-postgres;Port=5432;Database=${POSTGRES_DB};User ID=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}; COMPOSE_PROJECT_NAME=digdir + +# OTEL +OTEL_NAMESPACE=dialogporten-local +OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318 +OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf \ No newline at end of file diff --git a/.github/actions/azure-login/action.yml b/.github/actions/azure-login/action.yml new file mode 100644 index 000000000..5b8772979 --- /dev/null +++ b/.github/actions/azure-login/action.yml @@ -0,0 +1,28 @@ +name: 'Azure Login with Bicep Upgrade' +description: 'Login to Azure and upgrade Bicep CLI' + +inputs: + client-id: + description: 'Azure Client ID' + required: true + tenant-id: + description: 'Azure Tenant ID' + required: true + subscription-id: + description: 'Azure Subscription ID' + required: true +env: + AZ_CLI_VERSION: 2.67.0 +runs: + using: "composite" + steps: + - name: OIDC Login to Azure Public Cloud + uses: azure/login@v2 + with: + client-id: ${{ inputs.client-id }} + tenant-id: ${{ inputs.tenant-id }} + subscription-id: ${{ inputs.subscription-id }} + + - name: Upgrade Azure Bicep + shell: bash + run: az bicep upgrade \ No newline at end of file diff --git a/.github/slack-templates/pipeline-failed.json b/.github/slack-templates/pipeline-failed.json index d08909a3a..cd02b10a0 100644 --- a/.github/slack-templates/pipeline-failed.json +++ b/.github/slack-templates/pipeline-failed.json @@ -23,7 +23,7 @@ "type": "section", "text": { "type": "mrkdwn", - "text": "*Job Status:*\n• Infrastructure: ${{ env.INFRA_STATUS }}\n• Apps: ${{ env.APPS_STATUS }}\n• Slack Notifier: ${{ env.SLACK_NOTIFIER_STATUS }}\n• E2E Tests: ${{ env.E2E_TESTS_STATUS }}\n• Schema NPM: ${{ env.SCHEMA_NPM_STATUS }}\n• Publish: ${{ env.PUBLISH_STATUS }}" + "text": "*Job Status:*\n• Infrastructure: ${{ env.INFRA_STATUS }}\n• Apps: ${{ env.APPS_STATUS }}\n• Slack Notifier: ${{ env.SLACK_NOTIFIER_STATUS }}\n• E2E Tests: ${{ env.E2E_TESTS_STATUS }}\n• Performance Tests: ${{ env.PERFORMANCE_TESTS_STATUS }}\n• Schema NPM: ${{ env.SCHEMA_NPM_STATUS }}\n• Publish: ${{ env.PUBLISH_STATUS }}" } }, { diff --git a/.github/workflows/ci-cd-yt01.yml b/.github/workflows/ci-cd-yt01.yml index 734ff03ed..5be411d67 100644 --- a/.github/workflows/ci-cd-yt01.yml +++ b/.github/workflows/ci-cd-yt01.yml @@ -2,9 +2,9 @@ on: workflow_dispatch: - push: - tags: - - "v*.*.*" +# push: +# tags: +# - "v*.*.*" concurrency: group: ${{ github.workflow }}-${{ github.ref_name }} @@ -140,9 +140,42 @@ jobs: checks: write pull-requests: write + run-performance-tests: + name: "Run K6 performance tests" + # we want the performance tests to be dependent on deployment of infrastructure and apps, but if infrastructure is skipped, we still want to run the tests + if: ${{ always() && !failure() && !cancelled() && (github.event_name == 'workflow_dispatch' || needs.check-for-changes.outputs.hasBackendChanges == 'true' || needs.check-for-changes.outputs.hasInfraChanges == 'true') }} + needs: [deploy-apps, deploy-infra, check-for-changes] + #needs: [deploy-apps, check-for-changes] + uses: ./.github/workflows/workflow-run-k6-performance.yml + secrets: + TOKEN_GENERATOR_USERNAME: ${{ secrets.TOKEN_GENERATOR_USERNAME }} + TOKEN_GENERATOR_PASSWORD: ${{ secrets.TOKEN_GENERATOR_PASSWORD }} + K6_CLOUD_TOKEN: ${{ secrets.K6_CLOUD_TOKEN }} + K6_CLOUD_PROJECT_ID: ${{ secrets.K6_CLOUD_PROJECT_ID }} + + strategy: + max-parallel: 1 + matrix: + files: + - tests/k6/tests/serviceowner/serviceOwnerSearchWithThresholds.js + - tests/k6/tests/serviceowner/createDialogWithThresholds.js + - tests/k6/tests/enduser/enduserSearchWithThresholds.js + fail-fast: false + with: + environment: yt01 + apiVersion: v1 + vus: 1 + duration: 30s + tokens: both + numberOfTokens: 100 + testSuitePath: ${{ matrix.files }} + permissions: + checks: write + pull-requests: write + send-slack-message-on-failure: name: Send Slack message on failure - needs: [deploy-infra, deploy-apps, deploy-slack-notifier, run-e2e-tests, publish] + needs: [deploy-infra, deploy-apps, deploy-slack-notifier, run-e2e-tests, publish, run-performance-tests] if: ${{ always() && failure() && !cancelled() }} uses: ./.github/workflows/workflow-send-ci-cd-status-slack-message.yml with: @@ -151,6 +184,7 @@ jobs: apps_status: ${{ needs.deploy-apps.result }} slack_notifier_status: ${{ needs.deploy-slack-notifier.result }} e2e_tests_status: ${{ needs.run-e2e-tests.result }} + performance_tests_status: ${{ needs.run-performance-tests.result }} publish_status: ${{ needs.publish.result }} secrets: SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }} diff --git a/.github/workflows/workflow-deploy-apps.yml b/.github/workflows/workflow-deploy-apps.yml index a45653ef2..26d66c2e8 100644 --- a/.github/workflows/workflow-deploy-apps.yml +++ b/.github/workflows/workflow-deploy-apps.yml @@ -1,6 +1,4 @@ name: Deploy apps -env: - AZ_CLI_VERSION: 2.67.0 on: workflow_call: outputs: @@ -67,8 +65,8 @@ jobs: - name: "Checkout GitHub Action" uses: actions/checkout@v4 - - name: OIDC Login to Azure Public Cloud - uses: azure/login@v2 + - name: Azure Login + uses: ./.github/actions/azure-login with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} @@ -119,7 +117,6 @@ jobs: uses: azure/CLI@v2 if: ${{!inputs.dryRun}} with: - azcliversion: ${{ env.AZ_CLI_VERSION }} inlineScript: | az containerapp job start -n ${{ steps.deploy.outputs.name }} -g ${{ secrets.AZURE_RESOURCE_GROUP_NAME }} @@ -129,7 +126,6 @@ jobs: id: verify-migration timeout-minutes: 3 with: - azcliversion: ${{ env.AZ_CLI_VERSION }} inlineScript: | ./.github/tools/containerAppJobVerifier.sh ${{ steps.deploy.outputs.name }} ${{ secrets.AZURE_RESOURCE_GROUP_NAME }} ${{ inputs.version }} @@ -162,12 +158,13 @@ jobs: - name: "Checkout GitHub Action" uses: actions/checkout@v4 - - name: OIDC Login to Azure Public Cloud - uses: azure/login@v2 + - name: Azure Login + uses: ./.github/actions/azure-login with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + - name: Dryrun Deploy app ${{ matrix.name }}(${{ inputs.environment }}) uses: azure/arm-deploy@v2 if: ${{ inputs.dryRun }} @@ -223,7 +220,6 @@ jobs: id: verify-deployment timeout-minutes: 3 with: - azcliversion: ${{ env.AZ_CLI_VERSION }} inlineScript: | ./.github/tools/revisionVerifier.sh ${{ steps.deploy.outputs.revisionName }} ${{ secrets.AZURE_RESOURCE_GROUP_NAME }} @@ -252,8 +248,8 @@ jobs: - name: "Checkout GitHub Action" uses: actions/checkout@v4 - - name: OIDC Login to Azure Public Cloud - uses: azure/login@v2 + - name: Azure Login + uses: ./.github/actions/azure-login with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} diff --git a/.github/workflows/workflow-deploy-infra.yml b/.github/workflows/workflow-deploy-infra.yml index cbc7ebca3..014a0db79 100644 --- a/.github/workflows/workflow-deploy-infra.yml +++ b/.github/workflows/workflow-deploy-infra.yml @@ -1,8 +1,4 @@ name: Deploy infrastructure - -env: - AZ_CLI_VERSION: 2.67.0 - on: workflow_call: secrets: @@ -63,8 +59,8 @@ jobs: with: ref: ${{ inputs.ref }} - - name: OIDC Login to Azure Public Cloud - uses: azure/login@v2 + - name: Azure Login + uses: ./.github/actions/azure-login with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} @@ -74,7 +70,6 @@ jobs: uses: azure/CLI@v2 id: keyvault-keys with: - azcliversion: ${{ env.AZ_CLI_VERSION }} inlineScript: | KEY_VAULT_KEYS=$(az keyvault secret list --vault-name ${{ secrets.AZURE_SOURCE_KEY_VAULT_NAME }} --subscription ${{ secrets.AZURE_SOURCE_KEY_VAULT_SUBSCRIPTION_ID }} --query "[].name" -o json | tr -d '\n') echo "::set-output name=key-vault-keys::$KEY_VAULT_KEYS" diff --git a/.github/workflows/workflow-run-k6-performance.yml b/.github/workflows/workflow-run-k6-performance.yml index 17ddcadf5..88a839e6c 100644 --- a/.github/workflows/workflow-run-k6-performance.yml +++ b/.github/workflows/workflow-run-k6-performance.yml @@ -21,6 +21,14 @@ on: tokens: required: true type: string + numberOfTokens: + required: false + type: number + default: 0 + ttl: + required: false + type: number + default: 3600 secrets: TOKEN_GENERATOR_USERNAME: required: true @@ -45,9 +53,10 @@ jobs: uses: grafana/setup-k6-action@v1 - name: Run K6 tests (${{ inputs.testSuitePath }}) run: | - ./tests/k6/tests/scripts/generate_tokens.sh ./tests/k6/tests/performancetest_data ${{ inputs.tokens }} + ./tests/k6/tests/scripts/generate_tokens.sh ./tests/k6/tests/performancetest_data ${{ inputs.tokens }} ${{ inputs.numberOfTokens }} ${{ inputs.ttl }} + echo "Running k6 test suite ${{ inputs.testSuitePath }} with ${{ inputs.vus }} VUs for ${{ inputs.duration }}" k6 run ${{ inputs.testSuitePath }} --quiet --log-output=stdout --include-system-env-vars \ - --vus=${{ inputs.vus }} --duration=${{ inputs.duration }} --out=cloud --out csv=./results.csv + --vus=${{ inputs.vus }} --duration=${{ inputs.duration }} --out csv=./results.csv grep http_req_duration ./results.csv | sort --field-separator=',' --key=3 -nr | head -10 env: API_ENVIRONMENT: ${{ inputs.environment }} diff --git a/.github/workflows/workflow-send-ci-cd-status-slack-message.yml b/.github/workflows/workflow-send-ci-cd-status-slack-message.yml index 451814205..557f5d1e6 100644 --- a/.github/workflows/workflow-send-ci-cd-status-slack-message.yml +++ b/.github/workflows/workflow-send-ci-cd-status-slack-message.yml @@ -22,6 +22,10 @@ on: type: string description: "Status of the end-to-end tests job" default: "skipped" + performance_tests_status: + type: string + description: "Status of the performance tests job" + default: "skipped" schema_npm_status: type: string description: "Status of the schema npm publishing job" @@ -69,6 +73,7 @@ jobs: echo "SCHEMA_NPM_EMOJI=$(determine_emoji "${{ inputs.schema_npm_status }}")" echo "PUBLISH_EMOJI=$(determine_emoji "${{ inputs.publish_status }}")" echo "BUILD_AND_TEST_EMOJI=$(determine_emoji "${{ inputs.build_and_test_status }}")" + echo "PERFORMANCE_TESTS_EMOJI=$(determine_emoji "${{ inputs.performance_tests_status }}")" } >> "$GITHUB_OUTPUT" - name: Send GitHub slack message @@ -85,6 +90,7 @@ jobs: SCHEMA_NPM_STATUS: "${{ steps.status-emojis.outputs.SCHEMA_NPM_EMOJI }}" PUBLISH_STATUS: "${{ steps.status-emojis.outputs.PUBLISH_EMOJI }}" BUILD_AND_TEST_STATUS: "${{ steps.status-emojis.outputs.BUILD_AND_TEST_EMOJI }}" + PERFORMANCE_TESTS_STATUS: "${{ steps.status-emojis.outputs.PERFORMANCE_TESTS_EMOJI }}" uses: slackapi/slack-github-action@v2.0.0 with: errors: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 393e7b6a7..7c4199ee3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## [1.42.0](https://github.com/digdir/dialogporten/compare/v1.41.3...v1.42.0) (2024-12-16) + + +### Features + +* **apps:** add otel exporter for graphql, service and web-api ([#1528](https://github.com/digdir/dialogporten/issues/1528)) ([cb9238e](https://github.com/digdir/dialogporten/commit/cb9238ef76188b4dde371e08b7ce597645bcd8b7)) + +## [1.41.3](https://github.com/digdir/dialogporten/compare/v1.41.2...v1.41.3) (2024-12-13) + + +### Bug Fixes + +* **azure:** adjust SKU and storage for staging ([#1601](https://github.com/digdir/dialogporten/issues/1601)) ([3fb9f95](https://github.com/digdir/dialogporten/commit/3fb9f9501b4db97847aa1ebc0b77efe722811f0a)) +* Collapse subject resource mappings before building sql query ([#1579](https://github.com/digdir/dialogporten/issues/1579)) ([b39c376](https://github.com/digdir/dialogporten/commit/b39c37662f61361b083d7addc60b26ad4e06fab6)) +* **webapi:** Explicit null on non-nullable lists no longer causes 500 INTERNAL SERVER ERROR ([#1602](https://github.com/digdir/dialogporten/issues/1602)) ([2e8b3e6](https://github.com/digdir/dialogporten/commit/2e8b3e6db507efd195245ad829dd7d5a96f272ef)) + +## [1.41.2](https://github.com/digdir/dialogporten/compare/v1.41.1...v1.41.2) (2024-12-12) + + +### Bug Fixes + +* **webapi:** Set correct swagger return type for transmission list ([#1590](https://github.com/digdir/dialogporten/issues/1590)) ([6e88e0c](https://github.com/digdir/dialogporten/commit/6e88e0c13c089d0f4871be2ee95a7f74fb21a51c)) + ## [1.41.1](https://github.com/digdir/dialogporten/compare/v1.41.0...v1.41.1) (2024-12-09) diff --git a/README.md b/README.md index 0135ecec0..f258d0634 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,68 @@ Besides ordinary unit and integration tests, there are test suites for both func See `tests/k6/README.md` for more information. +## Health Checks + +The project includes integrated health checks that are exposed through standard endpoints: +- `/health/startup` - Dependency checks +- `/health/liveness` - Self checks +- `/health/readiness` - Critical service checks +- `/health` - General health status +- `/health/deep` - Comprehensive health check including external services + +These health checks are integrated with Azure Container Apps' health probe system and are used to monitor the application's health status. + +## Observability with OpenTelemetry + +This project uses OpenTelemetry for distributed tracing and metrics collection. The setup includes: + +### Core Features +- Distributed tracing across services +- Runtime and application metrics +- Integration with Azure Monitor/Application Insights +- Support for both OTLP and Azure Monitor exporters +- Automatic instrumentation for: + - ASP.NET Core + - HTTP clients + - Entity Framework Core + - PostgreSQL + - FusionCache + +### Configuration + +OpenTelemetry is configured through environment variables that are automatically provided by Azure Container Apps in production environments: + +```json +{ + "OTEL_SERVICE_NAME": "your-service-name", + "OTEL_EXPORTER_OTLP_ENDPOINT": "http://your-collector:4317", + "OTEL_EXPORTER_OTLP_PROTOCOL": "grpc", + "OTEL_RESOURCE_ATTRIBUTES": "key1=value1,key2=value2", + "APPLICATIONINSIGHTS_CONNECTION_STRING": "your-connection-string" +} +``` + +### Local Development + +For local development, the project includes a docker-compose setup with: +- OpenTelemetry Collector +- Grafana +- Other supporting services + +To run the local observability stack: +```bash +podman compose -f docker-compose-otel.yml up +``` + +### Request Filtering + +The telemetry setup includes smart filtering to: +- Exclude health check endpoints from tracing +- Filter out duplicate traces from Azure SDK clients +- Only record relevant HTTP client calls + +For more details about the OpenTelemetry setup, see the `ConfigureTelemetry` method in `AspNetUtilitiesExtensions.cs`. + ## Updating the SDK in global.json When RenovateBot updates `global.json` or base image versions in Dockerfiles, make sure they match. The `global.json` file should always have the same SDK version as the base image in the Dockerfiles. diff --git a/docker-compose-otel.yml b/docker-compose-otel.yml new file mode 100644 index 000000000..09e80c394 --- /dev/null +++ b/docker-compose-otel.yml @@ -0,0 +1,45 @@ +services: + # OpenTelemetry Collector + otel-collector: + image: otel/opentelemetry-collector-contrib:0.115.1 + command: ["--config=/etc/otel-collector-config.yaml"] + volumes: + - ./local-otel-configuration/otel-collector-config.yaml:/etc/otel-collector-config.yaml + ports: + - "4317:4317" # OTLP gRPC receiver + - "4318:4318" # OTLP http receiver + - "8888:8888" # Prometheus metrics exposed by the collector + - "8889:8889" # Prometheus exporter metrics + depends_on: + jaeger: + condition: service_healthy + + # Jaeger for trace visualization + jaeger: + image: jaegertracing/all-in-one:1.64.0 + ports: + - "16686:16686" # Jaeger UI + - "14250:14250" # Model used by collector + environment: + - COLLECTOR_OTLP_ENABLED=true + + # Prometheus for metrics + prometheus: + image: prom/prometheus:v3.0.1 + volumes: + - ./local-otel-configuration/prometheus.yml:/etc/prometheus/prometheus.yml + ports: + - "9090:9090" + + # Grafana for metrics visualization + grafana: + image: grafana/grafana:11.4.0 + ports: + - "3000:3000" + environment: + - GF_AUTH_ANONYMOUS_ENABLED=true + - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin + volumes: + - ./local-otel-configuration/grafana-datasources.yml:/etc/grafana/provisioning/datasources/datasources.yml + - ./local-otel-configuration/grafana-dashboards.yml:/etc/grafana/provisioning/dashboards/dashboards.yml + - ./local-otel-configuration/dashboards:/etc/grafana/provisioning/dashboards diff --git a/docker-compose.yml b/docker-compose.yml index f8b605880..4519148b5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,10 +1,11 @@ include: - docker-compose-db-redis.yml - docker-compose-cdc.yml + - docker-compose-otel.yml services: dialogporten-webapi-ingress: - image: nginx:1.27.2 + image: nginx:1.27.3 ports: - "7214:80" volumes: @@ -14,7 +15,7 @@ services: restart: always dialogporten-webapi: - scale: 2 + scale: 1 build: context: . dockerfile: src/Digdir.Domain.Dialogporten.WebApi/Dockerfile @@ -34,11 +35,15 @@ services: - Serilog__MinimumLevel__Default=Debug - ASPNETCORE_URLS=http://+:8080 - ASPNETCORE_ENVIRONMENT=Development + - OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT} + - OTEL_EXPORTER_OTLP_PROTOCOL=${OTEL_EXPORTER_OTLP_PROTOCOL} + - OTEL_SERVICE_NAME=dialogporten-webapi + - OTEL_RESOURCE_ATTRIBUTES=service.instance.id=dialogporten-webapi,service.namespace=${OTEL_NAMESPACE} volumes: - ./.aspnet/https:/https dialogporten-graphql-ingress: - image: nginx:1.27.2 + image: nginx:1.27.3 ports: - "7215:80" volumes: @@ -70,5 +75,9 @@ services: - Serilog__WriteTo__0__Name=Console - Serilog__MinimumLevel__Default=Debug - ASPNETCORE_ENVIRONMENT=Development + - OTEL_EXPORTER_OTLP_ENDPOINT=${OTEL_EXPORTER_OTLP_ENDPOINT} + - OTEL_EXPORTER_OTLP_PROTOCOL=${OTEL_EXPORTER_OTLP_PROTOCOL} + - OTEL_SERVICE_NAME=dialogporten-graphql + - OTEL_RESOURCE_ATTRIBUTES=service.instance.id=dialogporten-graphql,service.namespace=${OTEL_NAMESPACE} volumes: - ./.aspnet/https:/https diff --git a/docs/schema/V1/swagger.verified.json b/docs/schema/V1/swagger.verified.json index 01348f6fd..51d6c547a 100644 --- a/docs/schema/V1/swagger.verified.json +++ b/docs/schema/V1/swagger.verified.json @@ -315,7 +315,7 @@ "additionalProperties": false, "properties": { "mediaType": { - "description": "Media type of the content (plaintext, Markdown). Can also indicate that the content is embeddable.", + "description": "Media type of the content, this can also indicate that the content is embeddable.\nFor a list of supported media types, see (link TBD).", "type": "string" }, "value": { @@ -5853,6 +5853,9 @@ }, "description": "The UUID of the created the dialog aggregate. A relative URL to the newly created activity is set in the \u0022Location\u0022 header." }, + "204": { + "description": "No Content" + }, "400": { "content": { "application/problem\u002Bjson": { @@ -6468,6 +6471,9 @@ }, "description": "The UUID of the created the dialog activity. A relative URL to the newly created activity is set in the \u0022Location\u0022 header." }, + "204": { + "description": "No Content" + }, "400": { "content": { "application/problem\u002Bjson": { @@ -6741,7 +6747,10 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V1ServiceOwnerDialogTransmissionsQueriesSearch_Transmission" + "items": { + "$ref": "#/components/schemas/V1ServiceOwnerDialogTransmissionsQueriesSearch_Transmission" + }, + "type": "array" } } }, @@ -6825,6 +6834,9 @@ }, "description": "The UUID of the created the dialog transmission. A relative URL to the newly created activity is set in the \u0022Location\u0022 header." }, + "204": { + "description": "No Content" + }, "400": { "content": { "application/problem\u002Bjson": { @@ -6958,4 +6970,4 @@ "url": "https://altinn-dev-api.azure-api.net/dialogporten" } ] -} +} \ No newline at end of file diff --git a/local-otel-configuration/dashboards/aspnet-core-metrics.json b/local-otel-configuration/dashboards/aspnet-core-metrics.json new file mode 100644 index 000000000..f60749a2f --- /dev/null +++ b/local-otel-configuration/dashboards/aspnet-core-metrics.json @@ -0,0 +1,176 @@ +{ + "annotations": { + "list": [] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "title": "HTTP Request Duration", + "type": "timeseries", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "sum(rate(dialogporten_http_server_request_duration_seconds_bucket[$__rate_interval])) by (le)", + "legendFormat": "{{le}}", + "refId": "A" + } + ] + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "orientation": "auto", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showThresholdLabels": false, + "showThresholdMarkers": true + }, + "pluginVersion": "10.2.0", + "title": "Active Requests", + "type": "gauge", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "dialogporten_http_server_active_requests", + "refId": "A" + } + ] + } + ], + "refresh": "5s", + "schemaVersion": 38, + "style": "dark", + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-15m", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "ASP.NET Core Metrics", + "uid": "aspnet-core-metrics", + "version": 1, + "weekStart": "" +} \ No newline at end of file diff --git a/local-otel-configuration/dashboards/dashboards.yml b/local-otel-configuration/dashboards/dashboards.yml new file mode 100755 index 000000000..e69de29bb diff --git a/local-otel-configuration/dashboards/http-client-metrics.json b/local-otel-configuration/dashboards/http-client-metrics.json new file mode 100644 index 000000000..56e2f687e --- /dev/null +++ b/local-otel-configuration/dashboards/http-client-metrics.json @@ -0,0 +1,296 @@ +{ + "annotations": { + "list": [] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "title": "HTTP Client Active Requests & Connections", + "type": "timeseries", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "dialogporten_http_client_active_requests", + "legendFormat": "Active Requests", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "dialogporten_http_client_open_connections", + "legendFormat": "Open Connections", + "refId": "B" + } + ] + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "title": "Request Queue Time", + "type": "timeseries", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "rate(dialogporten_http_client_request_time_in_queue_seconds_sum[$__rate_interval]) / rate(dialogporten_http_client_request_time_in_queue_seconds_count[$__rate_interval])", + "legendFormat": "Average Queue Time", + "refId": "A" + } + ] + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "s" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "title": "DNS Lookup Duration", + "type": "timeseries", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "rate(dialogporten_dns_lookup_duration_seconds_sum[$__rate_interval]) / rate(dialogporten_dns_lookup_duration_seconds_count[$__rate_interval])", + "legendFormat": "Average DNS Lookup Time", + "refId": "A" + } + ] + } + ], + "refresh": "5s", + "schemaVersion": 38, + "style": "dark", + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-15m", + "to": "now" + }, + "title": "HTTP Client Metrics", + "uid": "http-client-metrics", + "version": 1, + "weekStart": "" +} \ No newline at end of file diff --git a/local-otel-configuration/dashboards/runtime-metrics.json b/local-otel-configuration/dashboards/runtime-metrics.json new file mode 100644 index 000000000..eed1ef0c6 --- /dev/null +++ b/local-otel-configuration/dashboards/runtime-metrics.json @@ -0,0 +1,390 @@ +{ + "annotations": { + "list": [] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "liveNow": false, + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + }, + "unit": "bytes" + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "title": "Memory Usage", + "type": "timeseries", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "dialogporten_process_runtime_dotnet_gc_heap_size_bytes", + "legendFormat": "Heap Size", + "refId": "A" + } + ] + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "title": "GC Collections", + "type": "timeseries", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "rate(dialogporten_process_runtime_dotnet_gc_collections_count_total[5m])", + "legendFormat": "Gen {{generation}}", + "refId": "A" + } + ] + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 3, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "title": "Thread Pool", + "type": "timeseries", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "dialogporten_process_runtime_dotnet_thread_pool_queue_length", + "legendFormat": "Queue Length", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "dialogporten_process_runtime_dotnet_thread_pool_threads_count", + "legendFormat": "Thread Count", + "refId": "B" + } + ] + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 20, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "lineInterpolation": "smooth", + "lineWidth": 2, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 4, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "single", + "sort": "none" + } + }, + "title": "Exceptions & Lock Contentions", + "type": "timeseries", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "rate(dialogporten_process_runtime_dotnet_exceptions_count_total[$__rate_interval])", + "legendFormat": "Exceptions/sec", + "refId": "A" + }, + { + "datasource": { + "type": "prometheus", + "uid": "Prometheus" + }, + "expr": "rate(dialogporten_process_runtime_dotnet_monitor_lock_contention_count_total[$__rate_interval])", + "legendFormat": "Lock Contentions/sec", + "refId": "B" + } + ] + } + ], + "refresh": "5s", + "schemaVersion": 38, + "style": "dark", + "tags": [], + "templating": { + "list": [] + }, + "time": { + "from": "now-15m", + "to": "now" + }, + "title": "Runtime Metrics", + "uid": "runtime-metrics", + "version": 1, + "weekStart": "" +} \ No newline at end of file diff --git a/local-otel-configuration/grafana-dashboards.yml b/local-otel-configuration/grafana-dashboards.yml new file mode 100644 index 000000000..3712f1164 --- /dev/null +++ b/local-otel-configuration/grafana-dashboards.yml @@ -0,0 +1,11 @@ +apiVersion: 1 + +providers: + - name: 'Default' + orgId: 1 + folder: '' + type: file + disableDeletion: false + editable: true + options: + path: /etc/grafana/provisioning/dashboards \ No newline at end of file diff --git a/local-otel-configuration/grafana-datasources.yml b/local-otel-configuration/grafana-datasources.yml new file mode 100644 index 000000000..4139ccba6 --- /dev/null +++ b/local-otel-configuration/grafana-datasources.yml @@ -0,0 +1,8 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + url: http://prometheus:9090 + isDefault: true \ No newline at end of file diff --git a/local-otel-configuration/otel-collector-config.yaml b/local-otel-configuration/otel-collector-config.yaml new file mode 100644 index 000000000..97bc0c2b6 --- /dev/null +++ b/local-otel-configuration/otel-collector-config.yaml @@ -0,0 +1,52 @@ +receivers: + otlp: + protocols: + http: + endpoint: 0.0.0.0:4318 + cors: + allowed_origins: ["*"] + allowed_headers: ["*"] + include_metadata: true + +processors: + batch: + timeout: 1s + send_batch_size: 1024 + +exporters: + prometheus: + endpoint: "0.0.0.0:8889" + namespace: "dialogporten" + otlp: + endpoint: "jaeger:4317" + tls: + insecure: true + debug: + verbosity: detailed + sampling_initial: 5 + sampling_thereafter: 200 + +extensions: + health_check: + pprof: + zpages: + +service: + extensions: [health_check, pprof, zpages] + telemetry: + logs: + level: debug + development: true + pipelines: + traces: + receivers: [otlp] + processors: [batch] + exporters: [otlp, debug] + metrics: + receivers: [otlp] + processors: [batch] + exporters: [prometheus, debug] + logs: + receivers: [otlp] + processors: [batch] + exporters: [debug] diff --git a/local-otel-configuration/prometheus.yml b/local-otel-configuration/prometheus.yml new file mode 100644 index 000000000..58650dae4 --- /dev/null +++ b/local-otel-configuration/prometheus.yml @@ -0,0 +1,8 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: 'otel-collector' + static_configs: + - targets: ['otel-collector:8889'] \ No newline at end of file diff --git a/renovate.json b/renovate.json index 74b545b9a..026d030e0 100644 --- a/renovate.json +++ b/renovate.json @@ -17,6 +17,24 @@ "schedule": [ "every 3 months" ] + }, + { + "packagePatterns": [ + "^Npgsql" + ], + "groupName": "Npgsql dependencies" + }, + { + "packagePatterns": [ + "^Microsoft" + ], + "groupName": "Microsoft dependencies" + }, + { + "packagePatterns": [ + "^Serilog" + ], + "groupName": "Serilog dependencies" } ] } diff --git a/sonar-project.properties b/sonar-project.properties index 5dc15fea4..dc4fd23e1 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,5 +1,5 @@ -# Disable code duplication detection for C# files -sonar.cpd.exclusions=**/*.cs +# Disable code duplication detection for C# and k6 files +sonar.cpd.exclusions=**/*.cs,**/k6/**/* # Disable all rules for auto-generated migration files sonar.exclusions=**/Migrations/**/* diff --git a/src/Digdir.Domain.Dialogporten.Application/Common/Extensions/DbSetExtensions.cs b/src/Digdir.Domain.Dialogporten.Application/Common/Extensions/DbSetExtensions.cs index a15186323..8d207682d 100644 --- a/src/Digdir.Domain.Dialogporten.Application/Common/Extensions/DbSetExtensions.cs +++ b/src/Digdir.Domain.Dialogporten.Application/Common/Extensions/DbSetExtensions.cs @@ -2,18 +2,15 @@ using System.Text; using Digdir.Domain.Dialogporten.Application.Externals.AltinnAuthorization; using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities; -using Digdir.Domain.Dialogporten.Domain.SubjectResources; using Microsoft.EntityFrameworkCore; namespace Digdir.Domain.Dialogporten.Application.Common.Extensions; public static class DbSetExtensions { - public static IQueryable PrefilterAuthorizedDialogs(this DbSet dialogs, DialogSearchAuthorizationResult authorizedResources) + public static (string sql, object[] parameters) GeneratePrefilterAuthorizedDialogsSql(DialogSearchAuthorizationResult authorizedResources) { var parameters = new List(); - - // lang=sql var sb = new StringBuilder() .AppendLine(CultureInfo.InvariantCulture, $""" SELECT * @@ -22,36 +19,50 @@ public static IQueryable PrefilterAuthorizedDialogs(this DbSet kv.Value, new HashSetEqualityComparer()) + .ToDictionary( + g => g.Key, + g => new HashSet(g.Select(kv => kv.Key)) + ); + + foreach (var (resources, parties) in groupedResult) { - // lang=sql sb.AppendLine(CultureInfo.InvariantCulture, $""" OR ( - "{nameof(DialogEntity.Party)}" = @p{parameters.Count} + "{nameof(DialogEntity.Party)}" = ANY(@p{parameters.Count}) AND "{nameof(DialogEntity.ServiceResource)}" = ANY(@p{parameters.Count + 1}) ) """); - parameters.Add(party); + parameters.Add(parties); parameters.Add(resources); } - foreach (var (party, subjects) in authorizedResources.SubjectsByParties) + return (sb.ToString(), parameters.ToArray()); + } + + public static IQueryable PrefilterAuthorizedDialogs(this DbSet dialogs, DialogSearchAuthorizationResult authorizedResources) + { + var (sql, parameters) = GeneratePrefilterAuthorizedDialogsSql(authorizedResources); + return dialogs.FromSqlRaw(sql, parameters); + } +} + + +public sealed class HashSetEqualityComparer : IEqualityComparer> +{ + public bool Equals(HashSet? x, HashSet? y) + { + return ReferenceEquals(x, y) || (x is not null && y is not null && x.SetEquals(y)); + } + + public int GetHashCode(HashSet obj) + { + ArgumentNullException.ThrowIfNull(obj); + unchecked { - // lang=sql - sb.AppendLine(CultureInfo.InvariantCulture, $""" - OR ( - "{nameof(DialogEntity.Party)}" = @p{parameters.Count} - AND "{nameof(DialogEntity.ServiceResource)}" = ANY( - SELECT "{nameof(SubjectResource.Resource)}" - FROM "{nameof(SubjectResource)}" - WHERE "{nameof(SubjectResource.Subject)}" = ANY(@p{parameters.Count + 1}) - ) - ) - """); - parameters.Add(party); - parameters.Add(subjects); + return obj.Aggregate(0, (hash, item) => hash ^ (item?.GetHashCode() ?? 0)); } - - return dialogs.FromSqlRaw(sb.ToString(), parameters.ToArray()); } } diff --git a/src/Digdir.Domain.Dialogporten.Application/Digdir.Domain.Dialogporten.Application.csproj b/src/Digdir.Domain.Dialogporten.Application/Digdir.Domain.Dialogporten.Application.csproj index 5a4988c7f..55cec0c47 100644 --- a/src/Digdir.Domain.Dialogporten.Application/Digdir.Domain.Dialogporten.Application.csproj +++ b/src/Digdir.Domain.Dialogporten.Application/Digdir.Domain.Dialogporten.Application.csproj @@ -12,9 +12,9 @@ - + - + diff --git a/src/Digdir.Domain.Dialogporten.Application/Externals/AltinnAuthorization/DialogSearchAuthorizationResult.cs b/src/Digdir.Domain.Dialogporten.Application/Externals/AltinnAuthorization/DialogSearchAuthorizationResult.cs index 838b979c4..4945413e7 100644 --- a/src/Digdir.Domain.Dialogporten.Application/Externals/AltinnAuthorization/DialogSearchAuthorizationResult.cs +++ b/src/Digdir.Domain.Dialogporten.Application/Externals/AltinnAuthorization/DialogSearchAuthorizationResult.cs @@ -4,12 +4,10 @@ public sealed class DialogSearchAuthorizationResult { // Resources here are "main" resources, eg. something that represents an entry in the Resource Registry // eg. "urn:altinn:resource:some-service" and referred to by "ServiceResource" in DialogEntity - public Dictionary> ResourcesByParties { get; init; } = new(); - public Dictionary> SubjectsByParties { get; init; } = new(); + public Dictionary> ResourcesByParties { get; init; } = new(); public List DialogIds { get; init; } = []; public bool HasNoAuthorizations => ResourcesByParties.Count == 0 - && DialogIds.Count == 0 - && SubjectsByParties.Count == 0; + && DialogIds.Count == 0; } diff --git a/src/Digdir.Domain.Dialogporten.Application/Features/V1/Common/Content/ContentValueDto.cs b/src/Digdir.Domain.Dialogporten.Application/Features/V1/Common/Content/ContentValueDto.cs index c3db7d6a5..58f5bf96c 100644 --- a/src/Digdir.Domain.Dialogporten.Application/Features/V1/Common/Content/ContentValueDto.cs +++ b/src/Digdir.Domain.Dialogporten.Application/Features/V1/Common/Content/ContentValueDto.cs @@ -11,7 +11,8 @@ public sealed class ContentValueDto public List Value { get; set; } = []; /// - /// Media type of the content (plaintext, Markdown). Can also indicate that the content is embeddable. + /// Media type of the content, this can also indicate that the content is embeddable. + /// For a list of supported media types, see (link TBD). /// public string MediaType { get; set; } = MediaTypes.PlainText; } diff --git a/src/Digdir.Domain.Dialogporten.ChangeDataCapture/Digdir.Domain.Dialogporten.ChangeDataCapture.csproj b/src/Digdir.Domain.Dialogporten.ChangeDataCapture/Digdir.Domain.Dialogporten.ChangeDataCapture.csproj index f5a137b9b..824248115 100644 --- a/src/Digdir.Domain.Dialogporten.ChangeDataCapture/Digdir.Domain.Dialogporten.ChangeDataCapture.csproj +++ b/src/Digdir.Domain.Dialogporten.ChangeDataCapture/Digdir.Domain.Dialogporten.ChangeDataCapture.csproj @@ -2,8 +2,8 @@ - - + + diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/Digdir.Domain.Dialogporten.GraphQL.csproj b/src/Digdir.Domain.Dialogporten.GraphQL/Digdir.Domain.Dialogporten.GraphQL.csproj index 7410002e9..47b62ee5d 100644 --- a/src/Digdir.Domain.Dialogporten.GraphQL/Digdir.Domain.Dialogporten.GraphQL.csproj +++ b/src/Digdir.Domain.Dialogporten.GraphQL/Digdir.Domain.Dialogporten.GraphQL.csproj @@ -10,9 +10,9 @@ - + - + diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/OpenTelemetryEventListener.cs b/src/Digdir.Domain.Dialogporten.GraphQL/OpenTelemetryEventListener.cs index 67beed8bb..b851f63f6 100644 --- a/src/Digdir.Domain.Dialogporten.GraphQL/OpenTelemetryEventListener.cs +++ b/src/Digdir.Domain.Dialogporten.GraphQL/OpenTelemetryEventListener.cs @@ -2,7 +2,6 @@ using HotChocolate.Execution; using HotChocolate.Execution.Instrumentation; using Microsoft.AspNetCore.Http.Extensions; -using OpenTelemetry.Trace; namespace Digdir.Domain.Dialogporten.GraphQL; diff --git a/src/Digdir.Domain.Dialogporten.GraphQL/Program.cs b/src/Digdir.Domain.Dialogporten.GraphQL/Program.cs index 7753936ac..5808a8408 100644 --- a/src/Digdir.Domain.Dialogporten.GraphQL/Program.cs +++ b/src/Digdir.Domain.Dialogporten.GraphQL/Program.cs @@ -68,12 +68,15 @@ static void BuildAndRun(string[] args, TelemetryConfiguration telemetryConfigura var thisAssembly = Assembly.GetExecutingAssembly(); - builder.ConfigureTelemetry(); - builder.Services.AddOpenTelemetry() - .WithTracing(tracerProviderBuilder => - { - tracerProviderBuilder.AddSource(DialogportenGraphQLSource); - }); + builder.ConfigureTelemetry((settings, configuration) => + { + settings.ServiceName = configuration["OTEL_SERVICE_NAME"] ?? builder.Environment.ApplicationName; + settings.Endpoint = configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]; + settings.Protocol = configuration["OTEL_EXPORTER_OTLP_PROTOCOL"]; + settings.AppInsightsConnectionString = configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]; + settings.ResourceAttributes = configuration["OTEL_RESOURCE_ATTRIBUTES"]; + settings.TraceSources.Add(DialogportenGraphQLSource); + }); builder.Services // Options setup diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/AltinnAuthorizationClient.cs b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/AltinnAuthorizationClient.cs index 22465274a..4ae95cb71 100644 --- a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/AltinnAuthorizationClient.cs +++ b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/AltinnAuthorizationClient.cs @@ -3,11 +3,14 @@ using System.Text.Json.Serialization; using Altinn.Authorization.ABAC.Xacml.JsonProfile; using Digdir.Domain.Dialogporten.Application.Common.Extensions; +using Digdir.Domain.Dialogporten.Application.Externals; using Digdir.Domain.Dialogporten.Application.Externals.AltinnAuthorization; using Digdir.Domain.Dialogporten.Application.Externals.Presentation; using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities; using Digdir.Domain.Dialogporten.Domain.Parties.Abstractions; +using Digdir.Domain.Dialogporten.Domain.SubjectResources; using Digdir.Domain.Dialogporten.Infrastructure.Common.Exceptions; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using ZiggyCreatures.Caching.Fusion; @@ -20,7 +23,9 @@ internal sealed class AltinnAuthorizationClient : IAltinnAuthorization private readonly HttpClient _httpClient; private readonly IFusionCache _pdpCache; private readonly IFusionCache _partiesCache; + private readonly IFusionCache _subjectResourcesCache; private readonly IUser _user; + private readonly IDialogDbContext _dialogDbContext; private readonly ILogger _logger; private static readonly JsonSerializerOptions SerializerOptions = new() @@ -33,12 +38,15 @@ public AltinnAuthorizationClient( HttpClient client, IFusionCacheProvider cacheProvider, IUser user, + IDialogDbContext dialogDbContext, ILogger logger) { _httpClient = client ?? throw new ArgumentNullException(nameof(client)); _pdpCache = cacheProvider.GetCache(nameof(Authorization)) ?? throw new ArgumentNullException(nameof(cacheProvider)); _partiesCache = cacheProvider.GetCache(nameof(AuthorizedPartiesResult)) ?? throw new ArgumentNullException(nameof(cacheProvider)); + _subjectResourcesCache = cacheProvider.GetCache(nameof(SubjectResource)) ?? throw new ArgumentNullException(nameof(cacheProvider)); _user = user ?? throw new ArgumentNullException(nameof(user)); + _dialogDbContext = dialogDbContext ?? throw new ArgumentNullException(nameof(dialogDbContext)); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); } @@ -94,7 +102,6 @@ public async Task HasListAuthorizationForDialog(DialogEntity dialog, Cance [dialog.Party], [dialog.ServiceResource], cancellationToken); return authorizedResourcesForSearch.ResourcesByParties.Count > 0 - || authorizedResourcesForSearch.SubjectsByParties.Count > 0 || authorizedResourcesForSearch.DialogIds.Contains(dialog.Id); } @@ -128,9 +135,9 @@ void Flatten(AuthorizedParty party, AuthorizedParty? parent = null) } private async Task PerformAuthorizedPartiesRequest(AuthorizedPartiesRequest authorizedPartiesRequest, - CancellationToken token) + CancellationToken cancellationToken) { - var authorizedPartiesDto = await SendAuthorizedPartiesRequest(authorizedPartiesRequest, token); + var authorizedPartiesDto = await SendAuthorizedPartiesRequest(authorizedPartiesRequest, cancellationToken); if (authorizedPartiesDto is null || authorizedPartiesDto.Count == 0) { throw new UpstreamServiceException("access-management returned no authorized parties, missing Altinn profile?"); @@ -139,10 +146,10 @@ private async Task PerformAuthorizedPartiesRequest(Auth return AuthorizedPartiesHelper.CreateAuthorizedPartiesResult(authorizedPartiesDto, authorizedPartiesRequest); } - private async Task PerformDialogSearchAuthorization(DialogSearchAuthorizationRequest request, CancellationToken token) + private async Task PerformDialogSearchAuthorization(DialogSearchAuthorizationRequest request, CancellationToken cancellationToken) { var partyIdentifier = request.Claims.GetEndUserPartyIdentifier() ?? throw new UnreachableException(); - var authorizedParties = await GetAuthorizedParties(partyIdentifier, flatten: true, cancellationToken: token); + var authorizedParties = await GetAuthorizedParties(partyIdentifier, flatten: true, cancellationToken: cancellationToken); if (request.ConstraintParties.Count > 0) { @@ -158,23 +165,27 @@ private async Task PerformDialogSearchAuthoriza p => p.Party, p => p.AuthorizedResources .Where(r => request.ConstraintServiceResources.Count == 0 || request.ConstraintServiceResources.Contains(r)) - .ToList()) + .ToHashSet()) // Skip parties with no authorized resources .Where(kv => kv.Value.Count != 0) .ToDictionary(kv => kv.Key, kv => kv.Value), - - SubjectsByParties = authorizedParties.AuthorizedParties - .ToDictionary( - p => p.Party, - p => p.AuthorizedRoles) - // Skip parties with no authorized roles - .Where(kv => kv.Value.Count != 0) - .ToDictionary(kv => kv.Key, kv => kv.Value) }; + await AuthorizationHelper.CollapseSubjectResources( + dialogSearchAuthorizationResult, + authorizedParties, + request.ConstraintServiceResources, + GetAllSubjectResources, + cancellationToken); + return dialogSearchAuthorizationResult; } + private async Task> GetAllSubjectResources(CancellationToken cancellationToken) => + await _subjectResourcesCache.GetOrSetAsync(nameof(SubjectResource), async ct + => await _dialogDbContext.SubjectResources.ToListAsync(cancellationToken: ct), + token: cancellationToken); + private async Task PerformDialogDetailsAuthorization( DialogDetailsAuthorizationRequest request, CancellationToken cancellationToken) { diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/AuthorizationHelper.cs b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/AuthorizationHelper.cs new file mode 100644 index 000000000..5eb395399 --- /dev/null +++ b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/AuthorizationHelper.cs @@ -0,0 +1,52 @@ +using Digdir.Domain.Dialogporten.Application.Externals.AltinnAuthorization; +using Digdir.Domain.Dialogporten.Domain.SubjectResources; + +namespace Digdir.Domain.Dialogporten.Infrastructure.Altinn.Authorization; + +internal static class AuthorizationHelper +{ + public static async Task CollapseSubjectResources( + DialogSearchAuthorizationResult dialogSearchAuthorizationResult, + AuthorizedPartiesResult authorizedParties, + List constraintResources, + Func>> getAllSubjectResources, + CancellationToken cancellationToken) + { + var authorizedPartiesWithRoles = authorizedParties.AuthorizedParties + .Where(p => p.AuthorizedRoles.Count != 0) + .ToList(); + + var uniqueSubjects = authorizedPartiesWithRoles + .SelectMany(p => p.AuthorizedRoles) + .ToHashSet(); + + var subjectResources = (await getAllSubjectResources(cancellationToken)) + .Where(x => uniqueSubjects.Contains(x.Subject) && (constraintResources.Count == 0 || constraintResources.Contains(x.Resource))).ToList(); + + var subjectToResources = subjectResources + .GroupBy(sr => sr.Subject) + .ToDictionary(g => g.Key, g => g.Select(sr => sr.Resource).ToHashSet()); + + foreach (var partyEntry in authorizedPartiesWithRoles) + { + if (!dialogSearchAuthorizationResult.ResourcesByParties.TryGetValue(partyEntry.Party, out var resourceList)) + { + resourceList = new HashSet(); + dialogSearchAuthorizationResult.ResourcesByParties[partyEntry.Party] = resourceList; + } + + foreach (var subject in partyEntry.AuthorizedRoles) + { + if (subjectToResources.TryGetValue(subject, out var subjectResourceSet)) + { + resourceList.UnionWith(subjectResourceSet); + } + } + + if (resourceList.Count == 0) + { + dialogSearchAuthorizationResult.ResourcesByParties.Remove(partyEntry.Party); + } + } + } +} diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/LocalDevelopmentAltinnAuthorization.cs b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/LocalDevelopmentAltinnAuthorization.cs index 6f7f5c07c..7e6b0593e 100644 --- a/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/LocalDevelopmentAltinnAuthorization.cs +++ b/src/Digdir.Domain.Dialogporten.Infrastructure/Altinn/Authorization/LocalDevelopmentAltinnAuthorization.cs @@ -40,19 +40,16 @@ public async Task GetAuthorizedResourcesForSear // Keep the number of parties and resources reasonable var allParties = dialogData.Select(x => x.Party).Distinct().Take(1000).ToList(); - var allResources = dialogData.Select(x => x.ServiceResource).Distinct().Take(1000).ToList(); - var allRoles = await _db.SubjectResources.Select(x => x.Subject).Distinct().Take(30).ToListAsync(cancellationToken); + var allResources = dialogData.Select(x => x.ServiceResource).Distinct().Take(1000).ToHashSet(); var authorizedResources = new DialogSearchAuthorizationResult { - ResourcesByParties = allParties.ToDictionary(party => party, _ => allResources), - SubjectsByParties = allParties.ToDictionary(party => party, _ => allRoles) + ResourcesByParties = allParties.ToDictionary(party => party, _ => allResources) }; return authorizedResources; } - [SuppressMessage("Performance", "CA1822:Mark members as static")] public async Task GetAuthorizedParties(IPartyIdentifier authenticatedParty, bool _ = false, CancellationToken __ = default) => await Task.FromResult(new AuthorizedPartiesResult { @@ -72,5 +69,5 @@ public async Task GetAuthorizedParties(IPartyIdentifier }] }); - public Task HasListAuthorizationForDialog(DialogEntity dialog, CancellationToken cancellationToken) => Task.FromResult(true); + public Task HasListAuthorizationForDialog(DialogEntity _, CancellationToken __) => Task.FromResult(true); } diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/Digdir.Domain.Dialogporten.Infrastructure.csproj b/src/Digdir.Domain.Dialogporten.Infrastructure/Digdir.Domain.Dialogporten.Infrastructure.csproj index 41f872f47..847c03471 100644 --- a/src/Digdir.Domain.Dialogporten.Infrastructure/Digdir.Domain.Dialogporten.Infrastructure.csproj +++ b/src/Digdir.Domain.Dialogporten.Infrastructure/Digdir.Domain.Dialogporten.Infrastructure.csproj @@ -3,18 +3,18 @@ - - + + - + - + - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/Digdir.Domain.Dialogporten.Infrastructure/InfrastructureExtensions.cs b/src/Digdir.Domain.Dialogporten.Infrastructure/InfrastructureExtensions.cs index 23289ef81..d0947c3dc 100644 --- a/src/Digdir.Domain.Dialogporten.Infrastructure/InfrastructureExtensions.cs +++ b/src/Digdir.Domain.Dialogporten.Infrastructure/InfrastructureExtensions.cs @@ -16,6 +16,7 @@ using Digdir.Domain.Dialogporten.Application; using Digdir.Domain.Dialogporten.Application.Common.Extensions; using Digdir.Domain.Dialogporten.Application.Externals.AltinnAuthorization; +using Digdir.Domain.Dialogporten.Domain.SubjectResources; using Digdir.Domain.Dialogporten.Infrastructure.Altinn.Authorization; using Digdir.Domain.Dialogporten.Infrastructure.Altinn.Events; using Digdir.Domain.Dialogporten.Infrastructure.Altinn.NameRegistry; @@ -160,6 +161,10 @@ internal static void AddInfrastructure_Internal(InfrastructureBuilderContext bui // Timeout for the cache to wait for the factory to complete, which when reached without fail-safe data // will cause an exception to be thrown FactoryHardTimeout = TimeSpan.FromSeconds(10) + }) + .ConfigureFusionCache(nameof(SubjectResource), new() + { + Duration = TimeSpan.FromMinutes(20) }); if (!environment.IsDevelopment()) diff --git a/src/Digdir.Domain.Dialogporten.Service/Program.cs b/src/Digdir.Domain.Dialogporten.Service/Program.cs index 3fffde09d..28a1c3a39 100644 --- a/src/Digdir.Domain.Dialogporten.Service/Program.cs +++ b/src/Digdir.Domain.Dialogporten.Service/Program.cs @@ -51,7 +51,14 @@ static void BuildAndRun(string[] args, TelemetryConfiguration telemetryConfigura .AddAzureConfiguration(builder.Environment.EnvironmentName) .AddLocalConfiguration(builder.Environment); - builder.ConfigureTelemetry(); + builder.ConfigureTelemetry((settings, configuration) => + { + settings.ServiceName = configuration["OTEL_SERVICE_NAME"] ?? builder.Environment.ApplicationName; + settings.Endpoint = configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]; + settings.Protocol = configuration["OTEL_EXPORTER_OTLP_PROTOCOL"]; + settings.AppInsightsConnectionString = configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]; + settings.ResourceAttributes = configuration["OTEL_RESOURCE_ATTRIBUTES"]; + }); builder.Services .AddAzureAppConfiguration() diff --git a/src/Digdir.Domain.Dialogporten.WebApi/Digdir.Domain.Dialogporten.WebApi.csproj b/src/Digdir.Domain.Dialogporten.WebApi/Digdir.Domain.Dialogporten.WebApi.csproj index 6216ac381..5b7f96d8b 100644 --- a/src/Digdir.Domain.Dialogporten.WebApi/Digdir.Domain.Dialogporten.WebApi.csproj +++ b/src/Digdir.Domain.Dialogporten.WebApi/Digdir.Domain.Dialogporten.WebApi.csproj @@ -8,23 +8,23 @@ - + - - + + - + - - - - + + + + diff --git a/src/Digdir.Domain.Dialogporten.WebApi/Endpoints/V1/ServiceOwner/DialogTransmissions/Search/SearchDialogTransmissionEndpoint.cs b/src/Digdir.Domain.Dialogporten.WebApi/Endpoints/V1/ServiceOwner/DialogTransmissions/Search/SearchDialogTransmissionEndpoint.cs index 06fb1aede..5af04f663 100644 --- a/src/Digdir.Domain.Dialogporten.WebApi/Endpoints/V1/ServiceOwner/DialogTransmissions/Search/SearchDialogTransmissionEndpoint.cs +++ b/src/Digdir.Domain.Dialogporten.WebApi/Endpoints/V1/ServiceOwner/DialogTransmissions/Search/SearchDialogTransmissionEndpoint.cs @@ -22,7 +22,7 @@ public override void Configure() Policies(AuthorizationPolicy.ServiceProvider); Group(); - Description(b => b.ProducesOneOf( + Description(b => b.ProducesOneOf>( StatusCodes.Status200OK, StatusCodes.Status404NotFound, StatusCodes.Status410Gone)); diff --git a/src/Digdir.Domain.Dialogporten.WebApi/OpenApiDocumentExtensions.cs b/src/Digdir.Domain.Dialogporten.WebApi/OpenApiDocumentExtensions.cs index 9dc187e41..a9d53de3f 100644 --- a/src/Digdir.Domain.Dialogporten.WebApi/OpenApiDocumentExtensions.cs +++ b/src/Digdir.Domain.Dialogporten.WebApi/OpenApiDocumentExtensions.cs @@ -1,4 +1,3 @@ -using Digdir.Domain.Dialogporten.WebApi.Endpoints.V1.ServiceOwner.Dialogs.Update; using NJsonSchema; using NSwag; diff --git a/src/Digdir.Domain.Dialogporten.WebApi/Program.cs b/src/Digdir.Domain.Dialogporten.WebApi/Program.cs index 21433a66e..6b58f6b5a 100644 --- a/src/Digdir.Domain.Dialogporten.WebApi/Program.cs +++ b/src/Digdir.Domain.Dialogporten.WebApi/Program.cs @@ -78,7 +78,14 @@ static void BuildAndRun(string[] args, TelemetryConfiguration telemetryConfigura var thisAssembly = Assembly.GetExecutingAssembly(); - builder.ConfigureTelemetry(); + builder.ConfigureTelemetry((settings, configuration) => + { + settings.ServiceName = configuration["OTEL_SERVICE_NAME"] ?? builder.Environment.ApplicationName; + settings.Endpoint = configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]; + settings.Protocol = configuration["OTEL_EXPORTER_OTLP_PROTOCOL"]; + settings.AppInsightsConnectionString = configuration["APPLICATIONINSIGHTS_CONNECTION_STRING"]; + settings.ResourceAttributes = configuration["OTEL_RESOURCE_ATTRIBUTES"]; + }); builder.Services // Options setup @@ -173,6 +180,7 @@ static void BuildAndRun(string[] args, TelemetryConfiguration telemetryConfigura new EndpointNameMetadata( TypeNameConverter.ToShortName(endpointDefinition.EndpointType))))); }; + x.Serializer.Options.RespectNullableAnnotations = true; x.Serializer.Options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; // Do not serialize empty collections x.Serializer.Options.TypeInfoResolver = new DefaultJsonTypeInfoResolver diff --git a/src/Digdir.Domain.Dialogporten.WebApi/appsettings.json b/src/Digdir.Domain.Dialogporten.WebApi/appsettings.json index ffd6358a4..938115f5a 100644 --- a/src/Digdir.Domain.Dialogporten.WebApi/appsettings.json +++ b/src/Digdir.Domain.Dialogporten.WebApi/appsettings.json @@ -19,4 +19,4 @@ } }, "AllowedHosts": "*" -} +} \ No newline at end of file diff --git a/src/Digdir.Library.Entity.Abstractions/Digdir.Library.Entity.Abstractions.csproj b/src/Digdir.Library.Entity.Abstractions/Digdir.Library.Entity.Abstractions.csproj index cb9c284f0..d9961bfe5 100644 --- a/src/Digdir.Library.Entity.Abstractions/Digdir.Library.Entity.Abstractions.csproj +++ b/src/Digdir.Library.Entity.Abstractions/Digdir.Library.Entity.Abstractions.csproj @@ -7,7 +7,7 @@ - + diff --git a/src/Digdir.Library.Entity.Abstractions/Features/Identifiable/IdentifiableExtensions.cs b/src/Digdir.Library.Entity.Abstractions/Features/Identifiable/IdentifiableExtensions.cs index 51db679dd..5c647cc26 100644 --- a/src/Digdir.Library.Entity.Abstractions/Features/Identifiable/IdentifiableExtensions.cs +++ b/src/Digdir.Library.Entity.Abstractions/Features/Identifiable/IdentifiableExtensions.cs @@ -32,11 +32,15 @@ public static Guid CreateVersion7IfDefault(this Guid value) => /// /// Creates a new version 7 UUID. + /// /// /// A new version 7 UUID in big endian format. - public static Guid CreateVersion7() => - // We want Guids in big endian format. The default behavior of Medo is big endian, - // however, the implicit conversion from Medo.Uuid7 to Guid is little endian. - // "matchGuidEndianness" is set to true to ensure big endian. - Uuid7.NewUuid7().ToGuid(matchGuidEndianness: true); + // We want Guids in big endian text representation. + // The default behavior of Medo is bigEndian text representation, + // Setting bigEndian to false for two reasons: + // 1. Use the parameter name explicitly in case the Medo API changes + // 2. Make it clear that we want big endian text representation, which means little endian byte order + public static Guid CreateVersion7(DateTimeOffset? timeStamp = null) => timeStamp is null + ? Uuid7.NewUuid7().ToGuid(bigEndian: false) + : Uuid7.NewUuid7(timeStamp.Value).ToGuid(bigEndian: false); } diff --git a/src/Digdir.Library.Utils.AspNet/AspNetUtilitiesExtensions.cs b/src/Digdir.Library.Utils.AspNet/AspNetUtilitiesExtensions.cs index c47413c8f..c1388ec0d 100644 --- a/src/Digdir.Library.Utils.AspNet/AspNetUtilitiesExtensions.cs +++ b/src/Digdir.Library.Utils.AspNet/AspNetUtilitiesExtensions.cs @@ -1,8 +1,8 @@ -using Azure.Monitor.OpenTelemetry.AspNetCore; using Digdir.Library.Utils.AspNet.HealthChecks; using HealthChecks.UI.Client; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Diagnostics.HealthChecks; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; using Microsoft.Extensions.Hosting; @@ -10,13 +10,15 @@ using OpenTelemetry.Trace; using OpenTelemetry.Metrics; using OpenTelemetry.Resources; +using OpenTelemetry; +using OpenTelemetry.Exporter; +using System.Diagnostics; +using Azure.Monitor.OpenTelemetry.Exporter; namespace Digdir.Library.Utils.AspNet; public static class AspNetUtilitiesExtensions { - private const string MassTransitSource = "MassTransit"; - public static IServiceCollection AddAspNetHealthChecks(this IServiceCollection services, Action? configure = null) { var optionsBuilder = services.AddOptions(); @@ -49,41 +51,115 @@ private static WebApplication MapHealthCheckEndpoint(this WebApplication app, st return app; } - public static WebApplicationBuilder ConfigureTelemetry(this WebApplicationBuilder builder) + public static WebApplicationBuilder ConfigureTelemetry( + this WebApplicationBuilder builder, + Action? configure = null) { - builder.Services.AddOpenTelemetry() - .ConfigureResource(resource => resource - .AddService(serviceName: builder.Environment.ApplicationName)) - .WithTracing(tracing => + var settings = new TelemetrySettings(); + configure?.Invoke(settings, builder.Configuration); + + Console.WriteLine($"[OpenTelemetry] Configuring telemetry for service: {settings.ServiceName}"); + + var telemetryBuilder = builder.Services.AddOpenTelemetry() + .ConfigureResource(resource => { - if (builder.Environment.IsDevelopment()) + var resourceBuilder = resource.AddService(serviceName: settings.ServiceName ?? builder.Environment.ApplicationName); + + var resourceAttributes = settings.ResourceAttributes; + if (string.IsNullOrEmpty(resourceAttributes)) return; + + try { - tracing.SetSampler(new AlwaysOnSampler()); + var attributes = resourceAttributes + .Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(pair => pair.Split('=', 2)) + .Where(parts => parts.Length == 2 && !string.IsNullOrEmpty(parts[0])) + .Select(parts => new KeyValuePair(parts[0].Trim(), parts[1].Trim())); + + foreach (var attribute in attributes) + { + resourceBuilder.AddAttributes([attribute]); + } } + catch (Exception ex) + { + throw new InvalidOperationException( + "Failed to parse OTEL_RESOURCE_ATTRIBUTES. Expected format: key1=value1,key2=value2", + ex + ); + } + }); + + if (!string.IsNullOrEmpty(settings.Endpoint) && !string.IsNullOrEmpty(settings.Protocol)) + { + Console.WriteLine($"[OpenTelemetry] Using endpoint: {settings.Endpoint}"); + Console.WriteLine($"[OpenTelemetry] Using protocol: {settings.Protocol}"); - tracing.AddAspNetCoreInstrumentation(options => + var otlpProtocol = settings.Protocol.ToLowerInvariant() switch + { + "grpc" => OtlpExportProtocol.Grpc, + "http/protobuf" => OtlpExportProtocol.HttpProtobuf, + "http" => OtlpExportProtocol.HttpProtobuf, + _ => throw new ArgumentException($"Unsupported protocol: {settings.Protocol}") + }; + + telemetryBuilder.UseOtlpExporter(otlpProtocol, new Uri(settings.Endpoint)); + + telemetryBuilder + .WithTracing(tracing => { - options.Filter = httpContext => - !httpContext.Request.Path.StartsWithSegments("/health"); + if (builder.Environment.IsDevelopment()) + { + tracing.SetSampler(new AlwaysOnSampler()); + } + + foreach (var source in settings.TraceSources) + { + tracing.AddSource(source); + } + + tracing + .AddAspNetCoreInstrumentation(opts => + { + opts.RecordException = true; + opts.Filter = httpContext => !httpContext.Request.Path.StartsWithSegments("/health"); + }) + .AddHttpClientInstrumentation(o => + { + o.RecordException = true; + o.FilterHttpRequestMessage = _ => + { + var parentActivity = Activity.Current?.Parent; + if (parentActivity != null && parentActivity.Source.Name.Equals("Azure.Core.Http", StringComparison.Ordinal)) + { + return false; + } + return true; + }; + }) + .AddEntityFrameworkCoreInstrumentation() + .AddNpgsql() + .AddFusionCacheInstrumentation(); }); - tracing.AddHttpClientInstrumentation(); - tracing.AddNpgsql(); - tracing.AddSource(MassTransitSource); // MassTransit ActivitySource - }) - .WithMetrics(metrics => + telemetryBuilder.WithMetrics(metrics => { - metrics.AddRuntimeInstrumentation(); - }); + metrics.AddRuntimeInstrumentation() + .AddAspNetCoreInstrumentation() + .AddHttpClientInstrumentation(); - if (!string.IsNullOrEmpty(Environment.GetEnvironmentVariable("APPLICATIONINSIGHTS_CONNECTION_STRING"))) - { - builder.Services.AddOpenTelemetry().UseAzureMonitor(); + if (!string.IsNullOrEmpty(settings.AppInsightsConnectionString)) + { + metrics.AddAzureMonitorMetricExporter(options => + { + options.ConnectionString = settings.AppInsightsConnectionString; + }); + } + }); } else { - // Use Application Insights SDK for local development - builder.Services.AddApplicationInsightsTelemetry(); + Console.WriteLine("[OpenTelemetry] OTLP exporter not configured - skipping"); } return builder; diff --git a/src/Digdir.Library.Utils.AspNet/AspNetUtilitiesSettings.cs b/src/Digdir.Library.Utils.AspNet/AspNetUtilitiesSettings.cs index f8e2f7fdb..7cb14248c 100644 --- a/src/Digdir.Library.Utils.AspNet/AspNetUtilitiesSettings.cs +++ b/src/Digdir.Library.Utils.AspNet/AspNetUtilitiesSettings.cs @@ -9,3 +9,21 @@ public sealed class HealthCheckSettings { public List HttpGetEndpointsToCheck { get; set; } = []; } + +public sealed class TelemetrySettings +{ + private const string MassTransitSource = "MassTransit"; + private const string AzureSource = "Azure.*"; + + public string? ServiceName { get; set; } + public string? Endpoint { get; set; } + public string? Protocol { get; set; } + public string? AppInsightsConnectionString { get; set; } + // Expected format: key1=value1,key2=value2 + public string? ResourceAttributes { get; set; } + public HashSet TraceSources { get; set; } = new() + { + AzureSource, + MassTransitSource + }; +} \ No newline at end of file diff --git a/src/Digdir.Library.Utils.AspNet/Digdir.Library.Utils.AspNet.csproj b/src/Digdir.Library.Utils.AspNet/Digdir.Library.Utils.AspNet.csproj index 807c8851a..aeb88adaa 100644 --- a/src/Digdir.Library.Utils.AspNet/Digdir.Library.Utils.AspNet.csproj +++ b/src/Digdir.Library.Utils.AspNet/Digdir.Library.Utils.AspNet.csproj @@ -11,12 +11,15 @@ + + - + - + + diff --git a/src/Digdir.Tool.Dialogporten.Benchmarks/Digdir.Tool.Dialogporten.Benchmarks.csproj b/src/Digdir.Tool.Dialogporten.Benchmarks/Digdir.Tool.Dialogporten.Benchmarks.csproj index 7a3ea8bd5..291f1a9dc 100644 --- a/src/Digdir.Tool.Dialogporten.Benchmarks/Digdir.Tool.Dialogporten.Benchmarks.csproj +++ b/src/Digdir.Tool.Dialogporten.Benchmarks/Digdir.Tool.Dialogporten.Benchmarks.csproj @@ -6,7 +6,7 @@ - + diff --git a/src/Digdir.Tool.Dialogporten.GenerateFakeData/DialogGenerator.cs b/src/Digdir.Tool.Dialogporten.GenerateFakeData/DialogGenerator.cs index 7b0af6a26..4cbd05a9d 100644 --- a/src/Digdir.Tool.Dialogporten.GenerateFakeData/DialogGenerator.cs +++ b/src/Digdir.Tool.Dialogporten.GenerateFakeData/DialogGenerator.cs @@ -242,7 +242,7 @@ public static List GenerateFakeDialogTransmissions(int? count = DialogTransmissionType.Values? type = null) { return new Faker() - .RuleFor(o => o.Id, _ => Uuid7.NewUuid7().ToGuid(true)) + .RuleFor(o => o.Id, _ => IdentifiableExtensions.CreateVersion7()) .RuleFor(o => o.CreatedAt, f => f.Date.Past()) .RuleFor(o => o.Type, f => type ?? f.PickRandom()) .RuleFor(o => o.Sender, _ => new() { ActorType = ActorType.Values.ServiceOwner }) @@ -262,7 +262,7 @@ public static List GenerateFakeDialogActivities(int? count = null, .Where(x => x != DialogActivityType.Values.TransmissionOpened).ToList(); return new Faker() - .RuleFor(o => o.Id, () => Uuid7.NewUuid7().ToGuid(true)) + .RuleFor(o => o.Id, () => IdentifiableExtensions.CreateVersion7()) .RuleFor(o => o.CreatedAt, f => f.Date.Past()) .RuleFor(o => o.ExtendedType, f => new Uri(f.Internet.UrlWithPath(Uri.UriSchemeHttps))) .RuleFor(o => o.Type, f => type ?? f.PickRandom(activityTypes)) diff --git a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Digdir.Domain.Dialogporten.Application.Integration.Tests.csproj b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Digdir.Domain.Dialogporten.Application.Integration.Tests.csproj index 8907b1cf5..8f8d3faa2 100644 --- a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Digdir.Domain.Dialogporten.Application.Integration.Tests.csproj +++ b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Digdir.Domain.Dialogporten.Application.Integration.Tests.csproj @@ -7,14 +7,14 @@ - - + + - + - + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/Common/Events/DomainEventsTests.cs b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/Common/Events/DomainEventsTests.cs index 115184cb9..306d3100a 100644 --- a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/Common/Events/DomainEventsTests.cs +++ b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/Common/Events/DomainEventsTests.cs @@ -11,9 +11,9 @@ using Digdir.Domain.Dialogporten.Application.Features.V1.ServiceOwner.Dialogs.Commands.Delete; using Digdir.Domain.Dialogporten.Domain.Attachments; using Digdir.Domain.Dialogporten.Domain.Dialogs.Events.Activities; +using Digdir.Library.Entity.Abstractions.Features.Identifiable; using MassTransit.Internals; using MassTransit.Testing; -using static Digdir.Domain.Dialogporten.Application.Integration.Tests.UuiDv7Utils; namespace Digdir.Domain.Dialogporten.Application.Integration.Tests.Features.V1.Common.Events; @@ -126,7 +126,7 @@ public async Task Creates_CloudEvent_When_Attachments_Updates() { // Arrange var harness = await Application.ConfigureServicesWithMassTransitTestHarness(); - var dialogId = GenerateBigEndianUuidV7(); + var dialogId = IdentifiableExtensions.CreateVersion7(); var createDialogCommand = DialogGenerator.GenerateFakeDialog( id: dialogId, attachments: []); @@ -174,7 +174,7 @@ public async Task Creates_CloudEvents_When_Dialog_Deleted() { // Arrange var harness = await Application.ConfigureServicesWithMassTransitTestHarness(); - var dialogId = GenerateBigEndianUuidV7(); + var dialogId = IdentifiableExtensions.CreateVersion7(); var createDialogCommand = DialogGenerator.GenerateFakeDialog(id: dialogId, attachments: [], activities: []); await Application.Send(createDialogCommand); @@ -204,7 +204,7 @@ public async Task Creates_DialogDeletedEvent_When_Dialog_Purged() { // Arrange var harness = await Application.ConfigureServicesWithMassTransitTestHarness(); - var dialogId = GenerateBigEndianUuidV7(); + var dialogId = IdentifiableExtensions.CreateVersion7(); var createDialogCommand = DialogGenerator.GenerateFakeDialog(id: dialogId, attachments: [], activities: []); await Application.Send(createDialogCommand); diff --git a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Dialogs/Commands/CreateDialogTests.cs b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Dialogs/Commands/CreateDialogTests.cs index 9e7323f2c..ef91ba303 100644 --- a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Dialogs/Commands/CreateDialogTests.cs +++ b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Dialogs/Commands/CreateDialogTests.cs @@ -5,11 +5,11 @@ using Digdir.Domain.Dialogporten.Application.Features.V1.ServiceOwner.Dialogs.Queries.Get; using Digdir.Domain.Dialogporten.Application.Integration.Tests.Common; using Digdir.Domain.Dialogporten.Domain; +using Digdir.Library.Entity.Abstractions.Features.Identifiable; using Digdir.Tool.Dialogporten.GenerateFakeData; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; -using static Digdir.Domain.Dialogporten.Application.Integration.Tests.UuiDv7Utils; namespace Digdir.Domain.Dialogporten.Application.Integration.Tests.Features.V1.ServiceOwner.Dialogs.Commands; @@ -38,8 +38,8 @@ public async Task Cant_Create_Dialog_With_UUIDv4_format() public async Task Cant_Create_Dialog_With_UUIDv7_In_Little_Endian_Format() { // Arrange - // Guid created with Medo, Uuid7.NewUuid7().ToGuid() - var invalidDialogId = Guid.Parse("638e9101-6bc7-7975-b392-ba5c5a528c23"); + // Guid created with Medo, Uuid7.NewUuid7().ToGuid(bigEndian: true) + var invalidDialogId = Guid.Parse("b2ca9301-c371-ab74-a87b-4ee1416b9655"); var createDialogCommand = DialogGenerator.GenerateSimpleFakeDialog(id: invalidDialogId); @@ -56,7 +56,7 @@ public async Task Cant_Create_Dialog_With_ID_With_Timestamp_In_The_Future() { // Arrange var timestamp = DateTimeOffset.UtcNow.AddSeconds(1); - var invalidDialogId = GenerateBigEndianUuidV7(timestamp); + var invalidDialogId = IdentifiableExtensions.CreateVersion7(timestamp); var createDialogCommand = DialogGenerator.GenerateSimpleFakeDialog(id: invalidDialogId); @@ -73,7 +73,7 @@ public async Task Create_Dialog_With_ID_With_Timestamp_In_The_Past() { // Arrange var timestamp = DateTimeOffset.UtcNow.AddSeconds(-1); - var validDialogId = GenerateBigEndianUuidV7(timestamp); + var validDialogId = IdentifiableExtensions.CreateVersion7(timestamp); var createDialogCommand = DialogGenerator.GenerateSimpleFakeDialog(id: validDialogId); @@ -90,7 +90,7 @@ public async Task Create_Dialog_With_ID_With_Timestamp_In_The_Past() public async Task Create_CreatesDialog_WhenDialogIsSimple() { // Arrange - var expectedDialogId = GenerateBigEndianUuidV7(); + var expectedDialogId = IdentifiableExtensions.CreateVersion7(); var createCommand = DialogGenerator.GenerateSimpleFakeDialog(id: expectedDialogId); // Act @@ -105,7 +105,7 @@ public async Task Create_CreatesDialog_WhenDialogIsSimple() public async Task Create_CreateDialog_WhenDialogIsComplex() { // Arrange - var expectedDialogId = GenerateBigEndianUuidV7(); + var expectedDialogId = IdentifiableExtensions.CreateVersion7(); var createDialogCommand = DialogGenerator.GenerateFakeDialog(id: expectedDialogId); // Act @@ -120,7 +120,7 @@ public async Task Create_CreateDialog_WhenDialogIsComplex() public async Task Can_Create_Dialog_With_UpdatedAt_Supplied() { // Arrange - var dialogId = GenerateBigEndianUuidV7(); + var dialogId = IdentifiableExtensions.CreateVersion7(); var createdAt = DateTimeOffset.UtcNow.AddYears(-20); var updatedAt = DateTimeOffset.UtcNow.AddYears(-15); var createDialogCommand = DialogGenerator.GenerateFakeDialog(id: dialogId, updatedAt: updatedAt, createdAt: createdAt); @@ -387,7 +387,7 @@ public async Task Cannot_Create_Title_Content_With_Embeddable_Html_MediaType_Wit public async Task Can_Create_MainContentRef_Content_With_Embeddable_Html_MediaType_With_Correct_Scope() { // Arrange - var expectedDialogId = GenerateBigEndianUuidV7(); + var expectedDialogId = IdentifiableExtensions.CreateVersion7(); var createDialogCommand = DialogGenerator.GenerateSimpleFakeDialog(id: expectedDialogId); createDialogCommand.Content.MainContentReference = new ContentValueDto { diff --git a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Dialogs/Commands/PurgeDialogTests.cs b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Dialogs/Commands/PurgeDialogTests.cs index f1da5c248..22adc5938 100644 --- a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Dialogs/Commands/PurgeDialogTests.cs +++ b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Dialogs/Commands/PurgeDialogTests.cs @@ -2,9 +2,9 @@ using Digdir.Domain.Dialogporten.Application.Integration.Tests.Common; using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities; using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities.Activities; +using Digdir.Library.Entity.Abstractions.Features.Identifiable; using Digdir.Tool.Dialogporten.GenerateFakeData; using FluentAssertions; -using static Digdir.Domain.Dialogporten.Application.Integration.Tests.UuiDv7Utils; namespace Digdir.Domain.Dialogporten.Application.Integration.Tests.Features.V1.ServiceOwner.Dialogs.Commands; @@ -15,7 +15,7 @@ public class PurgeDialogTests(DialogApplication application) : ApplicationCollec public async Task Purge_RemovesDialog_FromDatabase() { // Arrange - var expectedDialogId = GenerateBigEndianUuidV7(); + var expectedDialogId = IdentifiableExtensions.CreateVersion7(); var createCommand = DialogGenerator.GenerateFakeDialog(id: expectedDialogId); var createResponse = await Application.Send(createCommand); createResponse.TryPickT0(out _, out _).Should().BeTrue(); @@ -41,7 +41,7 @@ public async Task Purge_RemovesDialog_FromDatabase() public async Task Purge_ReturnsConcurrencyError_OnIfMatchDialogRevisionMismatch() { // Arrange - var expectedDialogId = GenerateBigEndianUuidV7(); + var expectedDialogId = IdentifiableExtensions.CreateVersion7(); var createCommand = DialogGenerator.GenerateFakeDialog(id: expectedDialogId); var createResponse = await Application.Send(createCommand); createResponse.TryPickT0(out _, out _).Should().BeTrue(); @@ -58,7 +58,7 @@ public async Task Purge_ReturnsConcurrencyError_OnIfMatchDialogRevisionMismatch( public async Task Purge_ReturnsNotFound_OnNonExistingDialog() { // Arrange - var expectedDialogId = GenerateBigEndianUuidV7(); + var expectedDialogId = IdentifiableExtensions.CreateVersion7(); var createCommand = DialogGenerator.GenerateFakeDialog(id: expectedDialogId); await Application.Send(createCommand); var purgeCommand = new PurgeDialogCommand { DialogId = expectedDialogId }; diff --git a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Dialogs/Queries/GetDialogTests.cs b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Dialogs/Queries/GetDialogTests.cs index 2493b064f..cadeb8a92 100644 --- a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Dialogs/Queries/GetDialogTests.cs +++ b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Dialogs/Queries/GetDialogTests.cs @@ -1,8 +1,8 @@ using Digdir.Domain.Dialogporten.Application.Features.V1.ServiceOwner.Dialogs.Queries.Get; using Digdir.Domain.Dialogporten.Application.Integration.Tests.Common; +using Digdir.Library.Entity.Abstractions.Features.Identifiable; using Digdir.Tool.Dialogporten.GenerateFakeData; using FluentAssertions; -using static Digdir.Domain.Dialogporten.Application.Integration.Tests.UuiDv7Utils; namespace Digdir.Domain.Dialogporten.Application.Integration.Tests.Features.V1.ServiceOwner.Dialogs.Queries; @@ -15,7 +15,7 @@ public GetDialogTests(DialogApplication application) : base(application) { } public async Task Get_ReturnsSimpleDialog_WhenDialogExists() { // Arrange - var expectedDialogId = GenerateBigEndianUuidV7(); + var expectedDialogId = IdentifiableExtensions.CreateVersion7(); var createDialogCommand = DialogGenerator.GenerateSimpleFakeDialog(id: expectedDialogId); var createCommandResponse = await Application.Send(createDialogCommand); @@ -36,7 +36,7 @@ public async Task Get_ReturnsSimpleDialog_WhenDialogExists() public async Task Get_ReturnsDialog_WhenDialogExists() { // Arrange - var expectedDialogId = GenerateBigEndianUuidV7(); + var expectedDialogId = IdentifiableExtensions.CreateVersion7(); var createCommand = DialogGenerator.GenerateFakeDialog(id: expectedDialogId); var createCommandResponse = await Application.Send(createCommand); diff --git a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Transmissions/Commands/CreateTransmissionTests.cs b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Transmissions/Commands/CreateTransmissionTests.cs index 27cfeb4a9..e0cd3e275 100644 --- a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Transmissions/Commands/CreateTransmissionTests.cs +++ b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Transmissions/Commands/CreateTransmissionTests.cs @@ -4,9 +4,9 @@ using Digdir.Domain.Dialogporten.Domain; using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities.Transmissions; using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities.Transmissions.Contents; +using Digdir.Library.Entity.Abstractions.Features.Identifiable; using Digdir.Tool.Dialogporten.GenerateFakeData; using FluentAssertions; -using static Digdir.Domain.Dialogporten.Application.Integration.Tests.UuiDv7Utils; namespace Digdir.Domain.Dialogporten.Application.Integration.Tests.Features.V1.ServiceOwner.Transmissions.Commands; @@ -19,7 +19,7 @@ public CreateTransmissionTests(DialogApplication application) : base(application public async Task Can_Create_Simple_Transmission() { // Arrange - var dialogId = GenerateBigEndianUuidV7(); + var dialogId = IdentifiableExtensions.CreateVersion7(); var createCommand = DialogGenerator.GenerateSimpleFakeDialog(id: dialogId); var transmission = DialogGenerator.GenerateFakeDialogTransmissions(1)[0]; @@ -41,10 +41,10 @@ public async Task Can_Create_Simple_Transmission() public async Task Can_Create_Transmission_With_Embeddable_Content() { // Arrange - var dialogId = GenerateBigEndianUuidV7(); + var dialogId = IdentifiableExtensions.CreateVersion7(); var createCommand = DialogGenerator.GenerateSimpleFakeDialog(id: dialogId); - var transmissionId = GenerateBigEndianUuidV7(); + var transmissionId = IdentifiableExtensions.CreateVersion7(); var transmission = DialogGenerator.GenerateFakeDialogTransmissions(1)[0]; const string contentUrl = "https://example.com/transmission"; diff --git a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Transmissions/Commands/UpdateTransmissionTests.cs b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Transmissions/Commands/UpdateTransmissionTests.cs index 261e36056..a7e57c4d9 100644 --- a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Transmissions/Commands/UpdateTransmissionTests.cs +++ b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/Features/V1/ServiceOwner/Transmissions/Commands/UpdateTransmissionTests.cs @@ -3,9 +3,9 @@ using Digdir.Domain.Dialogporten.Application.Integration.Tests.Common; using Digdir.Domain.Dialogporten.Domain.Actors; using Digdir.Domain.Dialogporten.Domain.Dialogs.Entities.Transmissions; +using Digdir.Library.Entity.Abstractions.Features.Identifiable; using Digdir.Tool.Dialogporten.GenerateFakeData; using FluentAssertions; -using static Digdir.Domain.Dialogporten.Application.Integration.Tests.UuiDv7Utils; namespace Digdir.Domain.Dialogporten.Application.Integration.Tests.Features.V1.ServiceOwner.Transmissions.Commands; @@ -122,7 +122,7 @@ public async Task Cannot_Include_Old_Transmissions_In_UpdateCommand() private static TransmissionDto UpdateDialogDialogTransmissionDto() => new() { - Id = GenerateBigEndianUuidV7(), + Id = IdentifiableExtensions.CreateVersion7(), Type = DialogTransmissionType.Values.Information, Sender = new() { ActorType = ActorType.Values.ServiceOwner }, Content = new() diff --git a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/UUIDv7Utils.cs b/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/UUIDv7Utils.cs deleted file mode 100644 index 040fac197..000000000 --- a/tests/Digdir.Domain.Dialogporten.Application.Integration.Tests/UUIDv7Utils.cs +++ /dev/null @@ -1,10 +0,0 @@ -using Medo; - -namespace Digdir.Domain.Dialogporten.Application.Integration.Tests; - -public static class UuiDv7Utils -{ - public static Guid GenerateBigEndianUuidV7(DateTimeOffset? timeStamp = null) => timeStamp is null ? - Uuid7.NewUuid7().ToGuid(matchGuidEndianness: true) - : Uuid7.NewUuid7(timeStamp.Value).ToGuid(matchGuidEndianness: true); -} diff --git a/tests/Digdir.Domain.Dialogporten.Application.Unit.Tests/DbSetExtensionsTests.cs b/tests/Digdir.Domain.Dialogporten.Application.Unit.Tests/DbSetExtensionsTests.cs new file mode 100644 index 000000000..9438ea885 --- /dev/null +++ b/tests/Digdir.Domain.Dialogporten.Application.Unit.Tests/DbSetExtensionsTests.cs @@ -0,0 +1,72 @@ +using Digdir.Domain.Dialogporten.Application.Common.Extensions; +using Digdir.Domain.Dialogporten.Application.Externals.AltinnAuthorization; +using FluentAssertions; + +namespace Digdir.Domain.Dialogporten.Application.Unit.Tests; + +public class DbSetExtensionsTests +{ + [Fact] + public void PrefilterAuthorizedDialogs_GeneratesExpectedSql_ForGroupedParties() + { + // Arrange + var authorizedResources = new DialogSearchAuthorizationResult + { + DialogIds = [Guid.CreateVersion7()], + ResourcesByParties = new Dictionary> + { + { "Party1", ["Resource1", "Resource2"] }, + { "Party2", ["Resource1", "Resource2"] }, + { "Party3", ["Resource1", "Resource2", "Resource3"] }, + { "Party4", ["Resource3"] }, + { "Party5", ["Resource4"] } + } + }; + + var expectedSql = """ + SELECT * + FROM "Dialog" + WHERE "Id" = ANY(@p0) + OR ( + "Party" = ANY(@p1) + AND "ServiceResource" = ANY(@p2) + ) + OR ( + "Party" = ANY(@p3) + AND "ServiceResource" = ANY(@p4) + ) + OR ( + "Party" = ANY(@p5) + AND "ServiceResource" = ANY(@p6) + ) + OR ( + "Party" = ANY(@p7) + AND "ServiceResource" = ANY(@p8) + ) + """; + var expectedParameters = new object[] + { + authorizedResources.DialogIds, + new HashSet { "Party1", "Party2" }, + new HashSet { "Resource1", "Resource2" }, + new HashSet { "Party3" }, + new HashSet { "Resource1", "Resource2", "Resource3" }, + new HashSet { "Party4" }, + new HashSet { "Resource3" }, + new HashSet { "Party5" }, + new HashSet { "Resource4" } + }; + + // Act + var (actualSql, actualParameters) = DbSetExtensions.GeneratePrefilterAuthorizedDialogsSql(authorizedResources); + + // Assert + RemoveWhitespace(actualSql).Should().Be(RemoveWhitespace(expectedSql)); + actualParameters.Should().BeEquivalentTo(expectedParameters); + } + + private static string RemoveWhitespace(string input) + { + return string.Concat(input.Where(c => !char.IsWhiteSpace(c))); + } +} diff --git a/tests/Digdir.Domain.Dialogporten.Application.Unit.Tests/Digdir.Domain.Dialogporten.Application.Unit.Tests.csproj b/tests/Digdir.Domain.Dialogporten.Application.Unit.Tests/Digdir.Domain.Dialogporten.Application.Unit.Tests.csproj index f01c3c17c..002f7ea39 100644 --- a/tests/Digdir.Domain.Dialogporten.Application.Unit.Tests/Digdir.Domain.Dialogporten.Application.Unit.Tests.csproj +++ b/tests/Digdir.Domain.Dialogporten.Application.Unit.Tests/Digdir.Domain.Dialogporten.Application.Unit.Tests.csproj @@ -10,7 +10,7 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + diff --git a/tests/Digdir.Domain.Dialogporten.Architecture.Tests/Digdir.Domain.Dialogporten.Architecture.Tests.csproj b/tests/Digdir.Domain.Dialogporten.Architecture.Tests/Digdir.Domain.Dialogporten.Architecture.Tests.csproj index d400b3da9..2d86c1eb2 100644 --- a/tests/Digdir.Domain.Dialogporten.Architecture.Tests/Digdir.Domain.Dialogporten.Architecture.Tests.csproj +++ b/tests/Digdir.Domain.Dialogporten.Architecture.Tests/Digdir.Domain.Dialogporten.Architecture.Tests.csproj @@ -7,8 +7,8 @@ - - + + diff --git a/tests/Digdir.Domain.Dialogporten.Infrastructure.Unit.Tests/AuthorizationHelperTests.cs b/tests/Digdir.Domain.Dialogporten.Infrastructure.Unit.Tests/AuthorizationHelperTests.cs new file mode 100644 index 000000000..7559b83c3 --- /dev/null +++ b/tests/Digdir.Domain.Dialogporten.Infrastructure.Unit.Tests/AuthorizationHelperTests.cs @@ -0,0 +1,78 @@ +using Digdir.Domain.Dialogporten.Application.Externals.AltinnAuthorization; +using Digdir.Domain.Dialogporten.Domain.SubjectResources; +using Digdir.Domain.Dialogporten.Infrastructure.Altinn.Authorization; +using Xunit; + +namespace Digdir.Domain.Dialogporten.Infrastructure.Unit.Tests; + +public class AuthorizationHelperTests +{ + [Fact] + public async Task CollapseSubjectResources_ShouldCollapseCorrectly() + { + // Arrange + var dialogSearchAuthorizationResult = new DialogSearchAuthorizationResult + { + ResourcesByParties = new Dictionary>() + }; + var authorizedParties = new AuthorizedPartiesResult + { + AuthorizedParties = new List + { + new() + { + Party = "party1", + AuthorizedRoles = new List { "role1", "role2" } + }, + new() + { + Party = "party2", + AuthorizedRoles = new List { "role2" } + }, + new() + { + Party = "party3", + AuthorizedRoles = new List { "role3" } + } + } + }; + var constraintResources = new List { "resource1", "resource2", "resource4" }; + + // Simulate subject resources + var subjectResources = new List + { + new() { Subject = "role1", Resource = "resource1" }, + new() { Subject = "role1", Resource = "resource2" }, + new() { Subject = "role2", Resource = "resource2" }, + new() { Subject = "role2", Resource = "resource3" }, + new() { Subject = "role2", Resource = "resource4" }, + new() { Subject = "role3", Resource = "resource5" }, // Note: not in constraintResources + }; + + Task> GetSubjectResources(CancellationToken token) + { + return Task.FromResult(subjectResources); + } + + // Act + await AuthorizationHelper.CollapseSubjectResources( + dialogSearchAuthorizationResult, + authorizedParties, + constraintResources, + GetSubjectResources, + CancellationToken.None); + + // Assert + Assert.Equal(2, dialogSearchAuthorizationResult.ResourcesByParties.Count); + Assert.Contains("party1", dialogSearchAuthorizationResult.ResourcesByParties.Keys); + Assert.Contains("resource1", dialogSearchAuthorizationResult.ResourcesByParties["party1"]); + Assert.Contains("resource2", dialogSearchAuthorizationResult.ResourcesByParties["party1"]); + Assert.Contains("resource4", dialogSearchAuthorizationResult.ResourcesByParties["party1"]); + Assert.Equal(3, dialogSearchAuthorizationResult.ResourcesByParties["party1"].Count); + + Assert.Contains("party2", dialogSearchAuthorizationResult.ResourcesByParties.Keys); + Assert.Contains("resource2", dialogSearchAuthorizationResult.ResourcesByParties["party2"]); + Assert.Contains("resource4", dialogSearchAuthorizationResult.ResourcesByParties["party2"]); + Assert.Equal(2, dialogSearchAuthorizationResult.ResourcesByParties["party2"].Count); + } +} diff --git a/tests/k6/tests/enduser/enduserSearchWithThresholds.js b/tests/k6/tests/enduser/enduserSearchWithThresholds.js new file mode 100644 index 000000000..8855cb07a --- /dev/null +++ b/tests/k6/tests/enduser/enduserSearchWithThresholds.js @@ -0,0 +1,28 @@ +import { default as run } from "./performance/enduser-search.js"; + +export let options = { + summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(95)', 'p(99)', 'p(99.5)', 'p(99.9)', 'count'], + vus: 1, + duration: "30s", + thresholds: { + "http_req_duration{name:enduser search}": ["p(95)<300", "p(99)<500"], + "http_req_duration{name:get dialog}": ["p(95)<300", "p(99)<500"], + "http_req_duration{name:get dialog activities}": ["p(95)<300", "p(99)<500"], + "http_req_duration{name:get dialog activity}": ["p(95)<300", "p(99)<500"], + "http_req_duration{name:get seenlogs}": ["p(95)<300", "p(99)<500"], + "http_req_duration{name:get transmissions}": ["p(95)<300", "p(99)<500"], + "http_req_duration{name:get transmission}": ["p(95)<300", "p(99)<500"], + "http_req_duration{name:get labellog}": ["p(95)<300", "p(99)<500"], + "http_reqs{name:enduser search}": [], + "http_reqs{name:get dialog activities}": [], + "http_reqs{name:get dialog activity}": [], + "http_reqs{name:get seenlogs}": [], + "http_reqs{name:get transmissions}": [], + "http_reqs{name:get transmission}": [], + "http_reqs{name:get dialog}": [], + "http_reqs{name:get labellog}": [], + } +} + +export default function (data) { run(data); } + diff --git a/tests/k6/tests/scripts/generate_tokens.sh b/tests/k6/tests/scripts/generate_tokens.sh index 4373de459..f9d39ab9a 100755 --- a/tests/k6/tests/scripts/generate_tokens.sh +++ b/tests/k6/tests/scripts/generate_tokens.sh @@ -11,11 +11,14 @@ usage() { echo "Usage: $0 " echo " : Path to the test data files" echo " : Type of tokens to generate (both, enterprise, or personal)" + echo " : limit number of tokens to generate. 0 means generate all" + echo " : Time to live in seconds for the generated tokens" + echo "Example: $0 /path/to/testdata both 10 3600" exit 1 } # Validate arguments -if [ $# -ne 2 ]; then +if [ $# -ne 4 ]; then usage fi @@ -37,6 +40,8 @@ esac testdatafilepath=$1 tokens=$2 +limit=$3 +ttl=$4 # Validate tokens argument if [[ ! "$tokens" =~ ^(both|enterprise|personal)$ ]]; then @@ -55,9 +60,13 @@ if [ "$tokens" = "both" ] || [ "$tokens" = "enterprise" ]; then exit 1 fi echo "org,orgno,scopes,resource,token" > $serviceowner_tokenfile + generated=0 while IFS=, read -r org orgno scopes resource do - url="https://altinn-testtools-token-generator.azurewebsites.net/api/GetEnterpriseToken?org=$org&env=$env&orgno=$orgno&ttl=3600" + if [ $limit -gt 0 ] && [ $generated -ge $limit ]; then + break + fi + url="https://altinn-testtools-token-generator.azurewebsites.net/api/GetEnterpriseToken?org=$org&env=$env&orgno=$orgno&ttl=$ttl" token=$(curl -s -f --get --data-urlencode "scopes=$scopes" $url -u "$tokengenuser:$tokengenpasswd" ) if [ $? -ne 0 ]; then echo "Error: Failed to generate enterprise token for: $env, $org, $orgno, $scopes " @@ -67,6 +76,8 @@ if [ "$tokens" = "both" ] || [ "$tokens" = "enterprise" ]; then status=$? if [ $status -ne 0 ]; then echo "Error: Failed to write enterprise token to file for: $env, $org, $orgno, $scopes" + else + ((generated++)) fi done < <(tail -n +2 $serviceowner_datafile) fi @@ -77,9 +88,13 @@ if [ "$tokens" = "both" ] || [ "$tokens" = "personal" ]; then exit 1 fi echo "ssn,resource,scopes,token" > $enduser_tokenfile + generated=0 while IFS=, read -r ssn resource scopes do - url="https://altinn-testtools-token-generator.azurewebsites.net/api/GetPersonalToken?env=$env&scopes=$scopes&pid=$ssn&ttl=3600" + if [ $limit -gt 0 ] && [ $generated -ge $limit ]; then + break + fi + url="https://altinn-testtools-token-generator.azurewebsites.net/api/GetPersonalToken?env=$env&scopes=$scopes&pid=$ssn&ttl=$ttl" token=$(curl -s -f $url -u "$tokengenuser:$tokengenpasswd" ) if [ $? -ne 0 ]; then echo "Error: Failed to generate personal token for: $ssn, $scopes " @@ -89,6 +104,8 @@ if [ "$tokens" = "both" ] || [ "$tokens" = "personal" ]; then status=$? if [ $status -ne 0 ]; then echo "Error: Failed to write personal token to file for: $ssn, $scopes" + else + ((generated++)) fi done < <(tail -n +2 $enduser_datafile) fi diff --git a/tests/k6/tests/serviceowner/createDialogWithThresholds.js b/tests/k6/tests/serviceowner/createDialogWithThresholds.js new file mode 100644 index 000000000..56b473244 --- /dev/null +++ b/tests/k6/tests/serviceowner/createDialogWithThresholds.js @@ -0,0 +1,12 @@ +import { default as run } from "./performance/create-dialog.js"; + +export let options = { + summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(95)', 'p(99)', 'p(99.5)', 'p(99.9)', 'count'], + vus: 1, + duration: "30s", + thresholds: { + "http_req_duration": ["p(95)<300", "p(99)<500"], + } +} + +export default function (data) { run(data); } \ No newline at end of file diff --git a/tests/k6/tests/serviceowner/serviceOwnerSearchWithThresholds.js b/tests/k6/tests/serviceowner/serviceOwnerSearchWithThresholds.js new file mode 100644 index 000000000..ed0fac469 --- /dev/null +++ b/tests/k6/tests/serviceowner/serviceOwnerSearchWithThresholds.js @@ -0,0 +1,24 @@ +import { default as run } from "./performance/serviceowner-search.js"; + +export let options = { + summaryTrendStats: ['avg', 'min', 'med', 'max', 'p(95)', 'p(99)', 'p(99.5)', 'p(99.9)', 'count'], + vus: 1, + duration: "30s", + thresholds: { + "http_req_duration{name:serviceowner search}": ["p(95)<100", "p(99)<300"], + "http_req_duration{name:get dialog activities}": ["p(95)<100", "p(99)<300"], + "http_req_duration{name:get dialog activity}": ["p(95)<100", "p(99)<300"], + "http_req_duration{name:get seenlogs}": ["p(95)<100", "p(99)<300"], + "http_req_duration{name:get transmissions}": ["p(95)<100", "p(99)<300"], + "http_req_duration{name:get transmission}": ["p(95)<100", "p(99)<300"], + "http_reqs{name:get dialog activities}": [], + "http_reqs{name:get dialog activity}": [], + "http_reqs{name:get seenlogs}": [], + "http_reqs{name:get transmissions}": [], + "http_reqs{name:get transmission}": [], + "http_reqs{name:serviceowner search}": [], + } +} + +export default function (data) { run(data); } + diff --git a/version.txt b/version.txt index f86fb9cbc..a50908ca3 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.41.1 +1.42.0