From 618a10e1ae84deb5d0f697cd3b842eb4fc5e585b Mon Sep 17 00:00:00 2001 From: waldo Date: Wed, 10 Jun 2026 16:03:22 +0200 Subject: [PATCH 1/4] feat(telemetry): add LogCustomEvent overloads to codeunit 73925 Add 4 public LogCustomEvent overloads (task-scoped and room-scoped, with/without ExtraDimensions) enabling room extensions to emit custom telemetry events that participate in leaderboard scoring. - Score clamped to -5..+5 to keep leaderboards balanced - Fixed event name EscapeRoomCustomEvent with EventId dimension - EventSource=Custom distinguishes from built-in events - Caller dimensions cannot overwrite standard keys - Version bumped to 1.4.0.0 (new public API, no breaking changes) --- .../Telemetry/EscapeRoomTelemetry.Codeunit.al | 80 +++++++++++++++++++ non production apps/EscapeRoomApp/app.json | 2 +- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/non production apps/EscapeRoomApp/Src/Telemetry/EscapeRoomTelemetry.Codeunit.al b/non production apps/EscapeRoomApp/Src/Telemetry/EscapeRoomTelemetry.Codeunit.al index 79519b36..5048d9ba 100644 --- a/non production apps/EscapeRoomApp/Src/Telemetry/EscapeRoomTelemetry.Codeunit.al +++ b/non production apps/EscapeRoomApp/Src/Telemetry/EscapeRoomTelemetry.Codeunit.al @@ -97,6 +97,86 @@ codeunit 73925 "Escape Room Telemetry" ); end; + /// Log a custom telemetry event scoped to a task. Standard task/room/venue dimensions are added automatically. + procedure LogCustomEvent(var EscapeRoomTask: Record "Escape Room Task"; EventId: Text; EventMessage: Text; ScorePoints: Integer) + var + ExtraDimensions: Dictionary of [Text, Text]; + begin + LogCustomEvent(EscapeRoomTask, EventId, EventMessage, ScorePoints, ExtraDimensions); + end; + + /// Log a custom telemetry event scoped to a room. Standard room/venue dimensions are added automatically. + procedure LogCustomEvent(var EscapeRoom: Record "Escape Room"; EventId: Text; EventMessage: Text; ScorePoints: Integer) + var + ExtraDimensions: Dictionary of [Text, Text]; + begin + LogCustomEvent(EscapeRoom, EventId, EventMessage, ScorePoints, ExtraDimensions); + end; + + /// Task-scoped custom event with additional caller-provided dimensions. + procedure LogCustomEvent(var EscapeRoomTask: Record "Escape Room Task"; EventId: Text; EventMessage: Text; ScorePoints: Integer; ExtraDimensions: Dictionary of [Text, Text]) + var + CustomDimensions: Dictionary of [Text, Text]; + begin + GetCustomDimensionsForTask(EscapeRoomTask, CustomDimensions); + AddScorePoints(CustomDimensions, ClampScorePoints(ScorePoints)); + AddCustomEventDimensions(CustomDimensions, EventId); + MergeExtraDimensions(CustomDimensions, ExtraDimensions); + + this.LogMessage( + 'EscapeRoomCustomEvent', + EventMessage, + CustomDimensions + ); + end; + + /// Room-scoped custom event with additional caller-provided dimensions. + procedure LogCustomEvent(var EscapeRoom: Record "Escape Room"; EventId: Text; EventMessage: Text; ScorePoints: Integer; ExtraDimensions: Dictionary of [Text, Text]) + var + CustomDimensions: Dictionary of [Text, Text]; + begin + GetCustomDimensionsForRoom(EscapeRoom, CustomDimensions); + AddScorePoints(CustomDimensions, ClampScorePoints(ScorePoints)); + AddCustomEventDimensions(CustomDimensions, EventId); + MergeExtraDimensions(CustomDimensions, ExtraDimensions); + + this.LogMessage( + 'EscapeRoomCustomEvent', + EventMessage, + CustomDimensions + ); + end; + + local procedure ClampScorePoints(ScorePoints: Integer): Integer + begin + if ScorePoints < -5 then + exit(-5); + if ScorePoints > 5 then + exit(5); + exit(ScorePoints); + end; + + local procedure AddCustomEventDimensions(var CustomDimensions: Dictionary of [Text, Text]; EventId: Text) + begin + EventId := EventId.Trim(); + if EventId = '' then + EventId := 'Unspecified'; + if StrLen(EventId) > 80 then + EventId := CopyStr(EventId, 1, 80); + + CustomDimensions.Add('EventId', EventId); + CustomDimensions.Add('EventSource', 'Custom'); + end; + + local procedure MergeExtraDimensions(var CustomDimensions: Dictionary of [Text, Text]; ExtraDimensions: Dictionary of [Text, Text]) + var + Key: Text; + begin + foreach Key in ExtraDimensions.Keys() do + if not CustomDimensions.ContainsKey(Key) then + CustomDimensions.Add(Key, ExtraDimensions.Get(Key)); + end; + local procedure GetCustomDimensionsForRoom(var Room: Record "Escape Room"; var CustomDimensions: Dictionary of [Text, Text]) var Venue: Record "Escape Room Venue"; diff --git a/non production apps/EscapeRoomApp/app.json b/non production apps/EscapeRoomApp/app.json index 73cf2916..f8e053ed 100644 --- a/non production apps/EscapeRoomApp/app.json +++ b/non production apps/EscapeRoomApp/app.json @@ -2,7 +2,7 @@ "id": "f03c0f0c-d887-4279-b226-dea59737ecf8", "name": "BCTalent.EscapeRoom", "publisher": "waldo & AJ", - "version": "1.3.10026.4", + "version": "1.4.0.0", "brief": "", "description": "", "privacyStatement": "", From 67f8adfdf34da5196efdd3c677209206f6dfea50 Mon Sep 17 00:00:00 2001 From: waldo Date: Wed, 10 Jun 2026 16:03:50 +0200 Subject: [PATCH 2/4] feat(leaderboard): include EscapeRoomCustomEvent in scoring queries - Add ALEscapeRoomCustomEvent to all scoring KQL queries and dashboard - Add CustomEvents/CustomEventPoints columns to breakdowns - Add new 'Custom Events Overview' audit query for facilitators --- .../Leaderboard/LeaderboardQueries.kql | 27 +++++++++++++++++-- .../dashboard-BCTalent.EscapeRooms.json | 6 ++--- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/non production apps/EscapeRoomApp/Leaderboard/LeaderboardQueries.kql b/non production apps/EscapeRoomApp/Leaderboard/LeaderboardQueries.kql index e41852e5..e4179222 100644 --- a/non production apps/EscapeRoomApp/Leaderboard/LeaderboardQueries.kql +++ b/non production apps/EscapeRoomApp/Leaderboard/LeaderboardQueries.kql @@ -51,6 +51,7 @@ EscapeRoomData VenuesCompleted = countif(eventId == "ALEscapeRoomVenueCompleted"), HintsUsed = countif(eventId == "ALEscapeRoomHintRequested"), SolutionsUsed = countif(eventId == "ALEscapeRoomSolutionRequested"), + CustomEvents = countif(eventId == "ALEscapeRoomCustomEvent"), LastActivity = max(taskStopDateTime), FastestVenueCompletion = min(VenueCompletionTimeMinutes) by Attendee, userId, Partner, environmentName @@ -83,12 +84,14 @@ EscapeRoomData VenueBonusPoints = sumif(Score, eventId == "ALEscapeRoomVenueCompleted"), HintPenalty = sumif(Score, eventId == "ALEscapeRoomHintRequested"), SolutionPenalty = sumif(Score, eventId == "ALEscapeRoomSolutionRequested"), + CustomEventPoints = sumif(Score, eventId == "ALEscapeRoomCustomEvent"), TotalScore = sum(Score), TasksCompleted = countif(eventId == "ALEscapeRoomTaskFinished"), RoomsCompleted = countif(eventId == "ALEscapeRoomCompleted"), VenuesCompleted = countif(eventId == "ALEscapeRoomVenueCompleted"), HintsUsed = countif(eventId == "ALEscapeRoomHintRequested"), SolutionsUsed = countif(eventId == "ALEscapeRoomSolutionRequested"), + CustomEvents = countif(eventId == "ALEscapeRoomCustomEvent"), FastestVenueCompletion = min(VenueCompletionTimeMinutes) by Attendee, Partner, environmentName | extend @@ -107,11 +110,13 @@ EscapeRoomData VenueBonusPoints, HintPenalty, SolutionPenalty, + CustomEventPoints, ScoreEfficiency, CompletionTimeMinutes, TasksCompleted, HintsUsed, - SolutionsUsed + SolutionsUsed, + CustomEvents | order by TotalScore desc, CompletionTimeMinutes asc nulls last // ================================================================= @@ -139,4 +144,22 @@ EscapeRoomData CompletionTimeMinutes, FinalScore, CompletionDate -| order by CompletionTimeMinutes asc \ No newline at end of file +| order by CompletionTimeMinutes asc + +// ================================================================= +// Query 4: Custom Events Overview +// ================================================================= +EscapeRoomData +| where eventId == "ALEscapeRoomCustomEvent" +| extend + CustomEventId = tostring(customDimensions.alEventId), + EventSource = tostring(customDimensions.alEventSource) +| project + timestamp, + Attendee, + Partner, + venueId, + roomName, + CustomEventId, + Score +| order by timestamp desc \ No newline at end of file diff --git a/non production apps/EscapeRoomApp/Leaderboard/dashboard-BCTalent.EscapeRooms.json b/non production apps/EscapeRoomApp/Leaderboard/dashboard-BCTalent.EscapeRooms.json index b5c8abe4..d40cdcaa 100644 --- a/non production apps/EscapeRoomApp/Leaderboard/dashboard-BCTalent.EscapeRooms.json +++ b/non production apps/EscapeRoomApp/Leaderboard/dashboard-BCTalent.EscapeRooms.json @@ -684,7 +684,7 @@ "kind": "inline", "dataSourceId": "803c0786-1793-49b9-8c52-5d977ac0c74c" }, - "text": "EscapeRoomData\n| where isnotempty(Attendee) and isnotempty(Score) and isnotempty( Partner)\n| extend AttendeePartner = strcat(Attendee,\" (\",Partner,\")\")\n| summarize \n TotalScore = sum(Score),\n TasksCompleted = countif(eventId == \"ALEscapeRoomTaskFinished\"),\n RoomsCompleted = countif(eventId == \"ALEscapeRoomCompleted\"),\n VenuesCompleted = countif(eventId == \"ALEscapeRoomVenueCompleted\"),\n HintsUsed = countif(eventId == \"ALEscapeRoomHintRequested\"),\n SolutionsUsed = countif(eventId == \"ALEscapeRoomSolutionRequested\"),\n LastActivity = max(taskStopDateTime)\n by AttendeePartner, userId, Partner, environmentName\n| order by TotalScore desc\n| extend Rank = row_number()\n| where Rank <= 10\n| project \n Rank,\n AttendeePartner,\n Partner,\n TotalScore,\n TasksCompleted,\n RoomsCompleted,\n VenuesCompleted,\n HintsUsed,\n SolutionsUsed,\n LastActivity\n| order by TotalScore desc", + "text": "EscapeRoomData\n| where isnotempty(Attendee) and isnotempty(Score) and isnotempty( Partner)\n| extend AttendeePartner = strcat(Attendee,\" (\",Partner,\")\")\n| summarize \n TotalScore = sum(Score),\n TasksCompleted = countif(eventId == \"ALEscapeRoomTaskFinished\"),\n RoomsCompleted = countif(eventId == \"ALEscapeRoomCompleted\"),\n VenuesCompleted = countif(eventId == \"ALEscapeRoomVenueCompleted\"),\n HintsUsed = countif(eventId == \"ALEscapeRoomHintRequested\"),\n SolutionsUsed = countif(eventId == \"ALEscapeRoomSolutionRequested\"),\n CustomEvents = countif(eventId == \"ALEscapeRoomCustomEvent\"),\n LastActivity = max(taskStopDateTime)\n by AttendeePartner, userId, Partner, environmentName\n| order by TotalScore desc\n| extend Rank = row_number()\n| where Rank <= 10\n| project \n Rank,\n AttendeePartner,\n Partner,\n TotalScore,\n TasksCompleted,\n RoomsCompleted,\n VenuesCompleted,\n HintsUsed,\n SolutionsUsed,\n CustomEvents,\n LastActivity\n| order by TotalScore desc", "id": "8a6d557a-34b9-4cad-8f4b-82a33fb0b883", "usedVariables": [ "EscapeRoomData" @@ -706,7 +706,7 @@ "kind": "inline", "dataSourceId": "803c0786-1793-49b9-8c52-5d977ac0c74c" }, - "text": "EscapeRoomData\n| where isnotempty(Attendee) and isnotempty(Score) and isnotempty(Partner)\n| summarize \n TaskPoints = sumif(Score, eventId == \"ALEscapeRoomTaskFinished\"),\n RoomBonusPoints = sumif(Score, eventId == \"ALEscapeRoomCompleted\"),\n VenueBonusPoints = sumif(Score, eventId == \"ALEscapeRoomVenueCompleted\"),\n HintPenalty = sumif(Score, eventId == \"ALEscapeRoomHintRequested\"),\n SolutionPenalty = sumif(Score, eventId == \"ALEscapeRoomSolutionRequested\"),\n TotalScore = sum(Score),\n TasksCompleted = countif(eventId == \"ALEscapeRoomTaskFinished\"),\n RoomsCompleted = countif(eventId == \"ALEscapeRoomCompleted\"),\n VenuesCompleted = countif(eventId == \"ALEscapeRoomVenueCompleted\"),\n HintsUsed = countif(eventId == \"ALEscapeRoomHintRequested\"),\n SolutionsUsed = countif(eventId == \"ALEscapeRoomSolutionRequested\"),\n FastestVenueCompletion = min(VenueCompletionTimeMinutes)\n by Attendee, Partner, environmentName\n| extend \n ScoreEfficiency = iif((TasksCompleted + HintsUsed + SolutionsUsed) > 0, round((todouble(TotalScore) / (TasksCompleted + HintsUsed + SolutionsUsed)) * 100) / 100, todouble(0)),\n CompletionTimeMinutes = iff(isnull(FastestVenueCompletion), real(null), round(FastestVenueCompletion, 2))\n| order by TotalScore desc, CompletionTimeMinutes asc nulls last\n| extend Rank = row_number()\n| where Rank <= 10\n| project \n Rank,\n Attendee,\n Partner,\n TotalScore,\n TaskPoints,\n RoomBonusPoints,\n VenueBonusPoints,\n HintPenalty,\n SolutionPenalty,\n ScoreEfficiency,\n CompletionTimeMinutes,\n TasksCompleted,\n HintsUsed,\n SolutionsUsed\n| order by TotalScore desc, CompletionTimeMinutes asc nulls last\n", + "text": "EscapeRoomData\n| where isnotempty(Attendee) and isnotempty(Score) and isnotempty(Partner)\n| summarize \n TaskPoints = sumif(Score, eventId == \"ALEscapeRoomTaskFinished\"),\n RoomBonusPoints = sumif(Score, eventId == \"ALEscapeRoomCompleted\"),\n VenueBonusPoints = sumif(Score, eventId == \"ALEscapeRoomVenueCompleted\"),\n HintPenalty = sumif(Score, eventId == \"ALEscapeRoomHintRequested\"),\n SolutionPenalty = sumif(Score, eventId == \"ALEscapeRoomSolutionRequested\"),\n CustomEventPoints = sumif(Score, eventId == \"ALEscapeRoomCustomEvent\"),\n TotalScore = sum(Score),\n TasksCompleted = countif(eventId == \"ALEscapeRoomTaskFinished\"),\n RoomsCompleted = countif(eventId == \"ALEscapeRoomCompleted\"),\n VenuesCompleted = countif(eventId == \"ALEscapeRoomVenueCompleted\"),\n HintsUsed = countif(eventId == \"ALEscapeRoomHintRequested\"),\n SolutionsUsed = countif(eventId == \"ALEscapeRoomSolutionRequested\"),\n CustomEvents = countif(eventId == \"ALEscapeRoomCustomEvent\"),\n FastestVenueCompletion = min(VenueCompletionTimeMinutes)\n by Attendee, Partner, environmentName\n| extend \n ScoreEfficiency = iif((TasksCompleted + HintsUsed + SolutionsUsed) > 0, round((todouble(TotalScore) / (TasksCompleted + HintsUsed + SolutionsUsed)) * 100) / 100, todouble(0)),\n CompletionTimeMinutes = iff(isnull(FastestVenueCompletion), real(null), round(FastestVenueCompletion, 2))\n| order by TotalScore desc, CompletionTimeMinutes asc nulls last\n| extend Rank = row_number()\n| where Rank <= 10\n| project \n Rank,\n Attendee,\n Partner,\n TotalScore,\n TaskPoints,\n RoomBonusPoints,\n VenueBonusPoints,\n HintPenalty,\n SolutionPenalty,\n CustomEventPoints,\n ScoreEfficiency,\n CompletionTimeMinutes,\n TasksCompleted,\n HintsUsed,\n SolutionsUsed,\n CustomEvents\n| order by TotalScore desc, CompletionTimeMinutes asc nulls last\n", "id": "e56b8546-96f6-4a9a-b0d8-6fdf90363676", "usedVariables": [ "EscapeRoomData" @@ -717,7 +717,7 @@ "kind": "inline", "dataSourceId": "803c0786-1793-49b9-8c52-5d977ac0c74c" }, - "text": "EscapeRoomData\n| where isnotempty(Attendee) and isnotempty(Score) and isnotempty(Partner)\n| summarize \n TotalScore = sum(Score),\n TasksCompleted = countif(eventId == \"ALEscapeRoomTaskFinished\"),\n RoomsCompleted = countif(eventId == \"ALEscapeRoomCompleted\"),\n VenuesCompleted = countif(eventId == \"ALEscapeRoomVenueCompleted\"),\n HintsUsed = countif(eventId == \"ALEscapeRoomHintRequested\"),\n SolutionsUsed = countif(eventId == \"ALEscapeRoomSolutionRequested\"),\n LastActivity = max(taskStopDateTime),\n FastestVenueCompletion = min(VenueCompletionTimeMinutes)\n by Attendee, userId, Partner, environmentName\n| extend CompletionTimeMinutes = iff(isnull(FastestVenueCompletion), real(null), round(FastestVenueCompletion, 2))\n| order by TotalScore desc, CompletionTimeMinutes asc nulls last\n| extend Rank = row_number()\n| where Rank <= 100\n| project \n Rank,\n Attendee,\n Partner,\n TotalScore,\n TasksCompleted,\n RoomsCompleted,\n VenuesCompleted,\n HintsUsed,\n SolutionsUsed,\n CompletionTimeMinutes,\n LastActivity\n| order by TotalScore desc, CompletionTimeMinutes asc nulls last", + "text": "EscapeRoomData\n| where isnotempty(Attendee) and isnotempty(Score) and isnotempty(Partner)\n| summarize \n TotalScore = sum(Score),\n TasksCompleted = countif(eventId == \"ALEscapeRoomTaskFinished\"),\n RoomsCompleted = countif(eventId == \"ALEscapeRoomCompleted\"),\n VenuesCompleted = countif(eventId == \"ALEscapeRoomVenueCompleted\"),\n HintsUsed = countif(eventId == \"ALEscapeRoomHintRequested\"),\n SolutionsUsed = countif(eventId == \"ALEscapeRoomSolutionRequested\"),\n CustomEvents = countif(eventId == \"ALEscapeRoomCustomEvent\"),\n LastActivity = max(taskStopDateTime),\n FastestVenueCompletion = min(VenueCompletionTimeMinutes)\n by Attendee, userId, Partner, environmentName\n| extend CompletionTimeMinutes = iff(isnull(FastestVenueCompletion), real(null), round(FastestVenueCompletion, 2))\n| order by TotalScore desc, CompletionTimeMinutes asc nulls last\n| extend Rank = row_number()\n| where Rank <= 100\n| project \n Rank,\n Attendee,\n Partner,\n TotalScore,\n TasksCompleted,\n RoomsCompleted,\n VenuesCompleted,\n HintsUsed,\n SolutionsUsed,\n CustomEvents,\n CompletionTimeMinutes,\n LastActivity\n| order by TotalScore desc, CompletionTimeMinutes asc nulls last", "id": "bc7c2e02-ff93-443d-93c0-a1e0db76e80c", "usedVariables": [ "EscapeRoomData" From ed94d5ae736cb81a9831e9295e7c7fd90431f0ba Mon Sep 17 00:00:00 2001 From: waldo Date: Wed, 10 Jun 2026 16:04:07 +0200 Subject: [PATCH 3/4] docs: document custom telemetry events API and leaderboard integration - Telemetry-Integration.md: add event type #8, custom events section - Creating-Rooms.md: add custom scoring subsection - API-Reference.md: add LogCustomEvent procedure docs - LeaderboardSetup.md: mention custom events in event types - CHANGELOG.md: add v1.4.0.0 entry --- .../EscapeRoomApp/Docs/CHANGELOG.md | 23 ++++++ .../EscapeRoomApp/Docs/Dev/API-Reference.md | 48 +++++++++++- .../Docs/Framework/Creating-Rooms.md | 18 ++++- .../Docs/Framework/Telemetry-Integration.md | 73 ++++++++++++++++++- .../Leaderboard/LeaderboardSetup.md | 5 ++ 5 files changed, 164 insertions(+), 3 deletions(-) create mode 100644 non production apps/EscapeRoomApp/Docs/CHANGELOG.md diff --git a/non production apps/EscapeRoomApp/Docs/CHANGELOG.md b/non production apps/EscapeRoomApp/Docs/CHANGELOG.md new file mode 100644 index 00000000..efe22bae --- /dev/null +++ b/non production apps/EscapeRoomApp/Docs/CHANGELOG.md @@ -0,0 +1,23 @@ +# Changelog + +All notable changes to the BCTalent.EscapeRoom framework are documented here. + +--- + +## [1.4.0.0] - 2026-06-10 + +### Added + +- **Custom Telemetry Events API** — New public `LogCustomEvent` overloads on codeunit 73925 "Escape Room Telemetry" allow room extension apps to emit custom scoring and diagnostic events. + - Task-scoped and room-scoped variants + - Optional `ExtraDimensions` parameter for caller-provided custom dimensions + - Score clamped to -5..+5 to keep leaderboards balanced + - All custom events use the fixed event name `EscapeRoomCustomEvent` with `EventSource = Custom` dimension +- **Custom Events Overview** KQL query in `LeaderboardQueries.kql` for facilitator auditing +- All scoring KQL queries and dashboard tiles now include `EscapeRoomCustomEvent` + +--- + +## [1.3.x] - Previous releases + +Initial framework with seven built-in telemetry events, interface-based venue/room/task system, leaderboard KQL queries, and Azure Data Explorer dashboard. diff --git a/non production apps/EscapeRoomApp/Docs/Dev/API-Reference.md b/non production apps/EscapeRoomApp/Docs/Dev/API-Reference.md index fca4b266..7f7e94a0 100644 --- a/non production apps/EscapeRoomApp/Docs/Dev/API-Reference.md +++ b/non production apps/EscapeRoomApp/Docs/Dev/API-Reference.md @@ -527,6 +527,52 @@ procedure UpdateStatus() --- +## Telemetry API — Codeunit 73925 "Escape Room Telemetry" + +### Built-in Event Procedures + +```al +procedure LogFinishedTask(var Task: Record "Escape Room Task") +procedure LogHintRequested(var Task: Record "Escape Room Task") +procedure LogSolutionRequested(var Room: record "Escape Room") +procedure LogRoomStarted(var Room: Record "Escape Room") +procedure LogRoomCompleted(var Room: Record "Escape Room") +procedure LogVenueCompleted(var Venue: Record "Escape Room Venue") +procedure LogNotification(NotificationText: Text) +``` + +### Custom Event Procedures + +```al +/// Task-scoped custom event (standard task/room/venue dimensions added automatically) +procedure LogCustomEvent(var EscapeRoomTask: Record "Escape Room Task"; EventId: Text; EventMessage: Text; ScorePoints: Integer) + +/// Room-scoped custom event (standard room/venue dimensions added automatically) +procedure LogCustomEvent(var EscapeRoom: Record "Escape Room"; EventId: Text; EventMessage: Text; ScorePoints: Integer) + +/// Task-scoped custom event with additional caller-provided dimensions +procedure LogCustomEvent(var EscapeRoomTask: Record "Escape Room Task"; EventId: Text; EventMessage: Text; ScorePoints: Integer; ExtraDimensions: Dictionary of [Text, Text]) + +/// Room-scoped custom event with additional caller-provided dimensions +procedure LogCustomEvent(var EscapeRoom: Record "Escape Room"; EventId: Text; EventMessage: Text; ScorePoints: Integer; ExtraDimensions: Dictionary of [Text, Text]) +``` + +**Parameters:** +- `EventId` — Caller-defined identifier (max 80 chars, trimmed; empty defaults to `Unspecified`). Recommend prefix with venue id, e.g. `DEV1-EasterEggFound`. +- `EventMessage` — Human-readable message logged to Application Insights. +- `ScorePoints` — Points added to leaderboard, clamped to -5..+5. +- `ExtraDimensions` — Optional additional dimensions. Keys that conflict with standard dimensions are silently skipped. + +**Behavior:** +- Application Insights event name is always `EscapeRoomCustomEvent`. +- Adds `EventId` and `EventSource = Custom` dimensions automatically. +- Standard dimensions (VenueId, VenueName, RoomName, TaskName, ScorePoints, etc.) cannot be overwritten by caller. +- Never throws — defensive against empty records. + +See [Telemetry Integration](../Framework/Telemetry-Integration.md#custom-events-for-room-developers) for full documentation. + +--- + ## Backward Compatibility Guarantee **Framework Version 1.x Promise:** @@ -553,4 +599,4 @@ procedure UpdateStatus() --- -**Last Updated:** January 7, 2026 +**Last Updated:** June 10, 2026 diff --git a/non production apps/EscapeRoomApp/Docs/Framework/Creating-Rooms.md b/non production apps/EscapeRoomApp/Docs/Framework/Creating-Rooms.md index ce47a43b..db9bb135 100644 --- a/non production apps/EscapeRoomApp/Docs/Framework/Creating-Rooms.md +++ b/non production apps/EscapeRoomApp/Docs/Framework/Creating-Rooms.md @@ -1011,6 +1011,22 @@ Once your venue is created: --- +## Custom Scoring with Telemetry + +Room apps can emit custom telemetry events for bonus challenges, easter eggs, or penalties using the `LogCustomEvent` overloads on codeunit 73925 "Escape Room Telemetry". Custom events carry the same standard dimensions as built-in events and participate in the leaderboard scoring pipeline (score clamped to -5..+5). + +```al +var + EscapeRoomTelemetry: Codeunit "Escape Room Telemetry"; +begin + EscapeRoomTelemetry.LogCustomEvent(Task, 'DEV1-BonusChallenge', 'Participant completed the bonus challenge.', 2); +end; +``` + +See [Telemetry Integration - Custom Events](Telemetry-Integration.md#custom-events-for-room-developers) for the full API reference, clamping rules, and EventId conventions. + +--- + ## Related Documentation - [Architecture Overview](Architecture.md) - Framework design patterns @@ -1020,4 +1036,4 @@ Once your venue is created: --- -**Last Updated:** January 7, 2026 +**Last Updated:** June 10, 2026 diff --git a/non production apps/EscapeRoomApp/Docs/Framework/Telemetry-Integration.md b/non production apps/EscapeRoomApp/Docs/Framework/Telemetry-Integration.md index e9ef9434..f27142d5 100644 --- a/non production apps/EscapeRoomApp/Docs/Framework/Telemetry-Integration.md +++ b/non production apps/EscapeRoomApp/Docs/Framework/Telemetry-Integration.md @@ -110,6 +110,24 @@ The framework logs seven distinct event types: --- +#### 8. **EscapeRoomCustomEvent** + +**When:** Room extension app calls `LogCustomEvent(...)` to emit a custom scoring or diagnostic event +**Score Impact:** -5 to +5 points (caller-defined, clamped) +**Custom Dimensions:** +- VenueId, VenueName +- PartnerName, FullName +- RoomName +- TaskName (when task-scoped) +- ScorePoints (clamped to -5..+5) +- EventId (caller-provided identifier, max 80 chars) +- EventSource: `Custom` +- Any additional caller-provided dimensions (cannot overwrite standard keys) + +**Logged By:** `LogCustomEvent(...)` overloads + +--- + ## Scoring System ### Point Values @@ -122,6 +140,7 @@ The framework logs seven distinct event types: | Request hint | **-1** | EscapeRoomHintRequested | | View solution | **-3** | EscapeRoomSolutionRequested | | Start room | **0** | EscapeRoomStarted | +| Custom event | **-5..+5** (caller-defined) | EscapeRoomCustomEvent | ### Scoring Examples @@ -159,6 +178,10 @@ procedure LogRoomStarted(var Room: Record "Escape Room") procedure LogRoomCompleted(var Room: Record "Escape Room") procedure LogVenueCompleted(var Venue: Record "Escape Room Venue") procedure LogNotification(NotificationText: Text) +procedure LogCustomEvent(var EscapeRoomTask: Record "Escape Room Task"; EventId: Text; EventMessage: Text; ScorePoints: Integer) +procedure LogCustomEvent(var EscapeRoom: Record "Escape Room"; EventId: Text; EventMessage: Text; ScorePoints: Integer) +procedure LogCustomEvent(var EscapeRoomTask: Record "Escape Room Task"; EventId: Text; EventMessage: Text; ScorePoints: Integer; ExtraDimensions: Dictionary of [Text, Text]) +procedure LogCustomEvent(var EscapeRoom: Record "Escape Room"; EventId: Text; EventMessage: Text; ScorePoints: Integer; ExtraDimensions: Dictionary of [Text, Text]) ``` **Internal Procedures:** @@ -221,6 +244,54 @@ Every telemetry event includes rich context: --- +## Custom Events (for room developers) + +Room extension apps can emit custom telemetry events using the `LogCustomEvent` overloads on codeunit 73925. Custom events participate in the standard leaderboard scoring pipeline. + +### API Signatures + +```al +// Task-scoped (includes task + room + venue dimensions) +procedure LogCustomEvent(var EscapeRoomTask: Record "Escape Room Task"; EventId: Text; EventMessage: Text; ScorePoints: Integer) +procedure LogCustomEvent(var EscapeRoomTask: Record "Escape Room Task"; EventId: Text; EventMessage: Text; ScorePoints: Integer; ExtraDimensions: Dictionary of [Text, Text]) + +// Room-scoped (includes room + venue dimensions) +procedure LogCustomEvent(var EscapeRoom: Record "Escape Room"; EventId: Text; EventMessage: Text; ScorePoints: Integer) +procedure LogCustomEvent(var EscapeRoom: Record "Escape Room"; EventId: Text; EventMessage: Text; ScorePoints: Integer; ExtraDimensions: Dictionary of [Text, Text]) +``` + +### Usage Example + +```al +// Award a bonus point when participant discovers an easter egg +procedure OnEasterEggFound(var Task: Record "Escape Room Task") +var + EscapeRoomTelemetry: Codeunit "Escape Room Telemetry"; +begin + EscapeRoomTelemetry.LogCustomEvent( + Task, + 'DEV1-EasterEggFound', + 'Participant discovered the hidden easter egg in Room 3.', + 2 // +2 bonus points + ); +end; +``` + +### Rules + +- **Score clamping:** `ScorePoints` is clamped to **-5..+5**. Values outside this range are silently clamped to the nearest bound. +- **EventId convention:** Prefix with your venue/app id, e.g. `DEV1-EasterEggFound`. Max 80 characters; empty defaults to `Unspecified`. +- **Event name:** All custom events use the fixed Application Insights message `EscapeRoomCustomEvent`. The caller's `EventId` is a custom dimension — not the event name. +- **EventSource dimension:** Always set to `Custom` to distinguish from built-in framework events. +- **No overwriting standard dimensions:** Caller-provided `ExtraDimensions` cannot overwrite standard keys (`VenueId`, `VenueName`, `RoomName`, `TaskName`, `ScorePoints`, `EventId`, `EventSource`, etc.). Conflicting keys are silently skipped. +- **Never throws:** The telemetry call is defensive — empty records produce empty dimension values, same as built-in behavior. + +### Leaderboard Impact + +Custom events flow into the standard leaderboard queries. The `ScorePoints` dimension is summed alongside all other scoring events. Use the "Custom Events Overview" query in `LeaderboardQueries.kql` to audit custom scoring during events. + +--- + ## Leaderboard Queries ### KQL Queries @@ -446,4 +517,4 @@ end; --- -**Last Updated:** January 7, 2026 +**Last Updated:** June 10, 2026 diff --git a/non production apps/EscapeRoomApp/Leaderboard/LeaderboardSetup.md b/non production apps/EscapeRoomApp/Leaderboard/LeaderboardSetup.md index 0c061052..211da722 100644 --- a/non production apps/EscapeRoomApp/Leaderboard/LeaderboardSetup.md +++ b/non production apps/EscapeRoomApp/Leaderboard/LeaderboardSetup.md @@ -173,6 +173,11 @@ The queries work with these primary event types: - **`ALEscapeRoomHintRequested`**: Hint usage with penalty - **`ALEscapeRoomSolutionRequested`**: Solution usage with penalty - **`ALEscapeRoomStarted`**: Room/venue start tracking +- **`ALEscapeRoomCustomEvent`**: Custom events emitted by room extensions (score -5..+5) + +### Custom Events & Scoring + +Room extension apps can emit custom telemetry events that participate in leaderboard scoring. These events use the `EscapeRoomCustomEvent` event name and carry a caller-defined score (clamped to -5..+5). Use the **"Custom Events Overview"** query in `LeaderboardQueries.kql` to audit custom scoring during an event — it lists timestamp, attendee, partner, venue, room, EventId, and ScorePoints for all custom events. ### Calculated Fields - **`durationMinutes`**: Calculated from start/stop timestamps From a7a26b7fb2bbd6f645a7e47146d41613ee0b6857 Mon Sep 17 00:00:00 2001 From: waldo Date: Tue, 15 Sep 2026 18:04:57 +0200 Subject: [PATCH 4/4] fix(escaperoom): prevent opening two rooms / closing venue prematurely UpdateStatus() on an already Completed room re-ran OpenNextRoom() unconditionally. Because that procedure searched for the next *Locked* room, it skipped the room already in progress and opened the one after it; once no locked room was left it stopped the venue even though rooms were still in progress. "Update Status", "Get Hint" and "Solve" on a completed room card all triggered it. - OpenNextRoom() is now idempotent: exits when a later room is already InProgress; closes the venue only via CloseVenueIfCompleted(). - Room/task/venue status transitions re-read the row under an update lock and re-check status before modifying, closing the race where the same completion ran in several sessions (event subscribers fired from the concurrency simulations' background sessions). - Room completion and next-room start are committed before the completion image is shown, so an interrupted session can no longer leave the next room locked. - Venue.Stop()/CloseVenueIfCompleted() return Boolean and no longer show UI themselves; Room.Stop() shows the completion images. - Rename local variable `Key` in codeunit 73925 (reserved word in current AL compilers; blocked the build). - Version 1.4.1.0, CHANGELOG and Architecture.md updated. Co-Authored-By: Claude Fable 5.1 --- .../EscapeRoomApp/Docs/CHANGELOG.md | 16 +++ .../Docs/Framework/Architecture.md | 23 ++-- .../Src/1.Venue/EscapeRoomVenue.Table.al | 34 ++++-- .../Src/2.Room/EscapeRoom.Table.al | 100 +++++++++++++----- .../Src/3.Task/EscapeRoomTask.Table.al | 29 +++-- .../Telemetry/EscapeRoomTelemetry.Codeunit.al | 8 +- non production apps/EscapeRoomApp/app.json | 2 +- 7 files changed, 158 insertions(+), 54 deletions(-) diff --git a/non production apps/EscapeRoomApp/Docs/CHANGELOG.md b/non production apps/EscapeRoomApp/Docs/CHANGELOG.md index efe22bae..a6ff3e6a 100644 --- a/non production apps/EscapeRoomApp/Docs/CHANGELOG.md +++ b/non production apps/EscapeRoomApp/Docs/CHANGELOG.md @@ -4,6 +4,22 @@ All notable changes to the BCTalent.EscapeRoom framework are documented here. --- +## [1.4.1.0] - 2026-09-14 + +### Fixed + +- **Two rooms could be opened / venue closed prematurely** — `UpdateStatus()` on an already *Completed* room re-ran `OpenNextRoom()` unconditionally. Because that procedure looked for the next *Locked* room, it skipped the room that was already in progress and opened the one after it; once no locked room was left it stopped the venue even though rooms were still in progress. Pressing "Update Status", "Get Hint" or "Solve" on a completed room card (reachable from the room list and from the task-completed notification) triggered it. + - `OpenNextRoom()` is now idempotent: it exits when a later room is already InProgress, and closes the venue only via `CloseVenueIfCompleted()` (all rooms Completed). + - Room, task and venue status transitions re-read the row under an update lock and re-check the status before modifying, closing the race where the same completion ran in several sessions at once (e.g. event subscribers fired from the concurrency simulations' background sessions). + - Room/venue state is committed before the completion image is shown, so an interrupted session can no longer leave the next room locked. +- Renamed a local variable named `Key` in codeunit 73925 "Escape Room Telemetry" (reserved word in current AL compilers, blocked the build). + +### Changed + +- `"Escape Room Venue".Stop()` and `CloseVenueIfCompleted()` now return `Boolean` (true when the venue got completed by that call) and no longer show the completion image themselves; `"Escape Room".Stop()` shows it. + +--- + ## [1.4.0.0] - 2026-06-10 ### Added diff --git a/non production apps/EscapeRoomApp/Docs/Framework/Architecture.md b/non production apps/EscapeRoomApp/Docs/Framework/Architecture.md index 3ba60807..d7b0dc13 100644 --- a/non production apps/EscapeRoomApp/Docs/Framework/Architecture.md +++ b/non production apps/EscapeRoomApp/Docs/Framework/Architecture.md @@ -363,9 +363,11 @@ codeunit 50103 "My Task Validation" implements "IEscape Room Task Validation" 9. Telemetry tracks all events for leaderboard **Room Navigation:** -- `OpenNextRoom()` procedure finds next room by sequence -- Sets current key and ascending order for proper ordering -- Only InProgress or NotStarted rooms can be opened +- `OpenNextRoom()` opens the first *Locked* room with a higher sequence than the room that was just completed +- It is idempotent: when a later room is already *InProgress* it does nothing, so calling it twice (concurrent sessions, or "Update Status" pressed on an already completed room) can never open a second room +- When no later room is left, it calls `CloseVenueIfCompleted()`, which closes the venue only when *every* room is Completed +- All status transitions (`Start()`, `Stop()` on rooms and tasks, `Stop()` on the venue) re-read the row under an update lock (`ReadIsolation = UpdLock`) and re-check the status before modifying, so two sessions completing the same task/room serialize instead of both performing the transition +- State transitions are committed *before* the completion image is shown, so an interrupted or UI-less (background) session cannot leave the next room locked --- @@ -398,12 +400,19 @@ procedure Start() ```al procedure UpdateStatus() -// Checks task completion and updates room status +// Re-validates open tasks and completes the room when none remain +// On an already completed room it only runs the (idempotent) OpenNextRoom() recovery procedure CloseRoomIfCompleted() -// Sets room to Completed when all tasks done -// Opens next room automatically -// Logs telemetry for room completion +// Completes the room when all tasks are done (calls Stop()) + +procedure Stop() +// Locks the row, sets Completed, commits, logs telemetry, +// opens the next room (or closes the venue), then shows the completion image(s) + +internal procedure OpenNextRoom() VenueCompleted: Boolean +// Opens the next Locked room; no-op when a later room is InProgress +// Returns true when the venue got completed by this call ``` ### Escape Room Task Table Procedures diff --git a/non production apps/EscapeRoomApp/Src/1.Venue/EscapeRoomVenue.Table.al b/non production apps/EscapeRoomApp/Src/1.Venue/EscapeRoomVenue.Table.al index 871dc2d7..e8335608 100644 --- a/non production apps/EscapeRoomApp/Src/1.Venue/EscapeRoomVenue.Table.al +++ b/non production apps/EscapeRoomApp/Src/1.Venue/EscapeRoomVenue.Table.al @@ -92,19 +92,34 @@ table 73926 "Escape Room Venue" Rec.StartFirstRoom(); end; - procedure Stop() + /// + /// Marks the venue as completed. Idempotent: a venue that already has a Stop DateTime is left alone. + /// Showing the completion image is up to the caller (see "Escape Room".Stop()). + /// + /// True when the venue got completed by this call. + procedure Stop(): Boolean var - Venue: Interface iEscapeRoomVenue; - EscapeRoomNotifications: Codeunit EscapeRoomNotifications; EscapeRoomTelemetry: Codeunit "Escape Room Telemetry"; begin + if not LockAndRefresh() then exit(false); + if Rec."Stop DateTime" <> 0DT then exit(false); + Rec."Stop DateTime" := CurrentDateTime(); Rec.Modify(); + Commit(); - Commit; - - EscapeRoomNotifications.venueFinished(Rec); EscapeRoomTelemetry.LogVenueCompleted(Rec); + exit(true); + end; + + local procedure LockAndRefresh(): Boolean + var + Found: Boolean; + begin + Rec.ReadIsolation := IsolationLevel::UpdLock; + Found := Rec.Find('='); + Rec.ReadIsolation := IsolationLevel::Default; + exit(Found); end; procedure StartFirstRoom() @@ -119,15 +134,16 @@ table 73926 "Escape Room Venue" Room.Start(); end; - procedure CloseVenueIfCompleted() + /// True when the venue got completed by this call. + procedure CloseVenueIfCompleted(): Boolean var Room: Record "Escape Room"; begin Room.Setrange("Venue Id", Rec.Id); Room.SetFilter(Status, '<>%1', Room.Status::Completed); - if not Room.IsEmpty then exit; + if not Room.IsEmpty then exit(false); - Rec.Stop(); + exit(Rec.Stop()); end; procedure RefreshRooms() diff --git a/non production apps/EscapeRoomApp/Src/2.Room/EscapeRoom.Table.al b/non production apps/EscapeRoomApp/Src/2.Room/EscapeRoom.Table.al index befb5d9b..37b9b333 100644 --- a/non production apps/EscapeRoomApp/Src/2.Room/EscapeRoom.Table.al +++ b/non production apps/EscapeRoomApp/Src/2.Room/EscapeRoom.Table.al @@ -89,9 +89,18 @@ table 73920 "Escape Room" procedure UpdateStatus() var Task: Record "Escape Room Task"; + Venue: Record "Escape Room Venue"; + EscapeRoomNotifications: Codeunit EscapeRoomNotifications; begin if Rec.Status = Rec.Status::Completed then begin - OpenNextRoom(); + // Recovery path only: if this room was completed but the next room never got started + // (e.g. the session was interrupted), open it now. OpenNextRoom() is idempotent and does + // nothing while a later room is already in progress, so pressing "Update Status" on a + // completed room can never open a second room or close the venue prematurely. + if OpenNextRoom() then begin + Venue.Get(Rec."Venue Id"); + EscapeRoomNotifications.VenueFinished(Venue); + end; exit; end; @@ -113,8 +122,6 @@ table 73920 "Escape Room" if not Task.IsEmpty() then exit; Rec.Stop(); - - OpenNextRoom(); end; procedure CloseRoomIfCompleted() @@ -129,59 +136,102 @@ table 73920 "Escape Room" if not task.IsEmpty then exit; Rec.Stop(); - - OpenNextRoom(); end; - internal procedure OpenNextRoom() + /// + /// Opens the next locked room after this one, or closes the venue when every room is completed. + /// Idempotent: does nothing when a later room is already in progress. + /// + /// True when the venue got completed by this call. + internal procedure OpenNextRoom() VenueCompleted: Boolean var NextRoom: Record "Escape Room"; Venue: Record "Escape Room Venue"; begin + NextRoom.ReadIsolation := IsolationLevel::UpdLock; NextRoom.SetCurrentKey(Sequence); NextRoom.Ascending := true; NextRoom.SetRange("Venue Id", Rec."Venue Id"); NextRoom.SetFilter(Sequence, '>%1', Rec.Sequence); + + // A later room is already open: nothing to do. This is what prevents a second room from + // being opened when this procedure runs twice (concurrent sessions, or "Update Status" + // pressed on an already completed room). + NextRoom.SetRange(Status, NextRoom.Status::InProgress); + if NextRoom.FindFirst() then + exit(false); + NextRoom.SetRange(Status, NextRoom.Status::Locked); if NextRoom.FindFirst() then begin NextRoom.Start(); - end - else begin - Venue.Get(Rec."Venue Id"); - Venue.Stop(); + exit(false); end; + + // No later room left to open. Close the venue, but only when every room is really completed. + Venue.Get(Rec."Venue Id"); + exit(Venue.CloseVenueIfCompleted()); end; procedure Start() var EscapeRoomTelemetry: Codeunit "Escape Room Telemetry"; begin - if Rec.Status = Rec.Status::Locked then begin - Rec.Status := Rec.Status::InProgress; - Rec."Start DateTime" := CurrentDateTime(); - Rec.Modify(); - Commit(); + if not LockAndRefresh() then exit; + if Rec.Status <> Rec.Status::Locked then exit; - EscapeRoomTelemetry.LogRoomStarted(Rec); - end; + Rec.Status := Rec.Status::InProgress; + Rec."Start DateTime" := CurrentDateTime(); + Rec.Modify(); + Commit(); + + EscapeRoomTelemetry.LogRoomStarted(Rec); end; + /// + /// Completes this room, opens the next one and then shows the completion image(s). + /// The state transitions are committed before any UI is shown, so an interrupted or + /// UI-less (background) session can no longer leave the next room locked. + /// procedure Stop() var + Venue: Record "Escape Room Venue"; EscapeRoomNotifications: Codeunit EscapeRoomNotifications; EscapeRoomTelemetry: Codeunit "Escape Room Telemetry"; + VenueCompleted: Boolean; begin - if Rec.Status = Rec.Status::InProgress then begin - Rec.Status := Rec.Status::Completed; - Rec."Stop DateTime" := CurrentDateTime(); - Rec.Modify(); - Commit(); - - EscapeRoomNotifications.RoomFinished(Rec); - EscapeRoomTelemetry.LogRoomCompleted(Rec); + if not LockAndRefresh() then exit; + if Rec.Status <> Rec.Status::InProgress then exit; + + Rec.Status := Rec.Status::Completed; + Rec."Stop DateTime" := CurrentDateTime(); + Rec.Modify(); + Commit(); + + EscapeRoomTelemetry.LogRoomCompleted(Rec); + + VenueCompleted := OpenNextRoom(); + + EscapeRoomNotifications.RoomFinished(Rec); + if VenueCompleted then begin + Venue.Get(Rec."Venue Id"); + EscapeRoomNotifications.VenueFinished(Venue); end; end; + /// + /// Re-reads this room from the database while taking an update lock on its row, so that + /// concurrent sessions serialize on the status transition instead of both performing it. + /// + local procedure LockAndRefresh(): Boolean + var + Found: Boolean; + begin + Rec.ReadIsolation := IsolationLevel::UpdLock; + Found := Rec.Find('='); + Rec.ReadIsolation := IsolationLevel::Default; + exit(Found); + end; + procedure GetHint() var TaskRec: Record "Escape Room Task"; diff --git a/non production apps/EscapeRoomApp/Src/3.Task/EscapeRoomTask.Table.al b/non production apps/EscapeRoomApp/Src/3.Task/EscapeRoomTask.Table.al index 3e794eb6..dc7a145b 100644 --- a/non production apps/EscapeRoomApp/Src/3.Task/EscapeRoomTask.Table.al +++ b/non production apps/EscapeRoomApp/Src/3.Task/EscapeRoomTask.Table.al @@ -107,14 +107,27 @@ table 73922 "Escape Room Task" EscapeRoomNotifications: Codeunit EscapeRoomNotifications; EscapeRoomTelemetry: Codeunit "Escape Room Telemetry"; begin - if Rec.Status = Rec.Status::Open then begin - Rec.Status := Rec.Status::Completed; - Rec."Stop DateTime" := CurrentDateTime(); - Rec.Modify(); - Commit(); + // Re-read under an update lock: task completion is often triggered from event subscribers + // that fire in several sessions at once (e.g. the concurrency simulations). + if not LockAndRefresh() then exit; + if Rec.Status <> Rec.Status::Open then exit; - EscapeRoomNotifications.TaskFinished(Rec); - EscapeRoomTelemetry.LogFinishedTask(Rec); - end + Rec.Status := Rec.Status::Completed; + Rec."Stop DateTime" := CurrentDateTime(); + Rec.Modify(); + Commit(); + + EscapeRoomTelemetry.LogFinishedTask(Rec); + EscapeRoomNotifications.TaskFinished(Rec); + end; + + local procedure LockAndRefresh(): Boolean + var + Found: Boolean; + begin + Rec.ReadIsolation := IsolationLevel::UpdLock; + Found := Rec.Find('='); + Rec.ReadIsolation := IsolationLevel::Default; + exit(Found); end; } \ No newline at end of file diff --git a/non production apps/EscapeRoomApp/Src/Telemetry/EscapeRoomTelemetry.Codeunit.al b/non production apps/EscapeRoomApp/Src/Telemetry/EscapeRoomTelemetry.Codeunit.al index 5048d9ba..e7534970 100644 --- a/non production apps/EscapeRoomApp/Src/Telemetry/EscapeRoomTelemetry.Codeunit.al +++ b/non production apps/EscapeRoomApp/Src/Telemetry/EscapeRoomTelemetry.Codeunit.al @@ -170,11 +170,11 @@ codeunit 73925 "Escape Room Telemetry" local procedure MergeExtraDimensions(var CustomDimensions: Dictionary of [Text, Text]; ExtraDimensions: Dictionary of [Text, Text]) var - Key: Text; + DimensionKey: Text; begin - foreach Key in ExtraDimensions.Keys() do - if not CustomDimensions.ContainsKey(Key) then - CustomDimensions.Add(Key, ExtraDimensions.Get(Key)); + foreach DimensionKey in ExtraDimensions.Keys() do + if not CustomDimensions.ContainsKey(DimensionKey) then + CustomDimensions.Add(DimensionKey, ExtraDimensions.Get(DimensionKey)); end; local procedure GetCustomDimensionsForRoom(var Room: Record "Escape Room"; var CustomDimensions: Dictionary of [Text, Text]) diff --git a/non production apps/EscapeRoomApp/app.json b/non production apps/EscapeRoomApp/app.json index f8e053ed..1bc7963d 100644 --- a/non production apps/EscapeRoomApp/app.json +++ b/non production apps/EscapeRoomApp/app.json @@ -2,7 +2,7 @@ "id": "f03c0f0c-d887-4279-b226-dea59737ecf8", "name": "BCTalent.EscapeRoom", "publisher": "waldo & AJ", - "version": "1.4.0.0", + "version": "1.4.1.0", "brief": "", "description": "", "privacyStatement": "",