Scheduling a feature toggle using no-code with Azure Logic Apps
I use launch darkly to toggle features on an app. There is one third-party dependency that has regular scheduled maintenance and I need to toggle the feature on and off on schedule.
Launch Darkly has built in scheduling to handle this scenario but you have to be on the enterprise plan to use it. The enterprise plan is too expensive to upgrade to for scheduling alone so I needed to find a different way to automate this.
No-code on Azure
I had a couple of non-functional requirements that steered me towards a no code solution.
- I need to implement this quickly and move on to product features
- It had to have built in scheduling
- It needed to be easily maintained by anyone
- It needed to have easy access to change the schedule by anyone (a GUI for parameters)
- I had to be able to secure the Launch Darkly api key because we're writing feature values
Azure provides a no code type platform called Logic Apps that sounded perfect for this kind of workflow problem.
Summary
I'll describe each of the steps in detail so you understand why I had to add them.
I've provided the full JSON configuration at the end of this article so it's easy to recreate the logic app.
All of this can be set up using the Azure Logic Apps GUI. I'll add the json configuration for the steps where you can't see all the inputs in the screenshots.
Remember that copying this configuration wont work right away for you. You'll have to set up your own connections for the Azure Key Vault and Slack integrations using the logic app web UI.
The full logic app designer view
Here's everything together!
Okay, let's get started!
Add the logic app
Create a new Logic App in a resource group and you can use the consumption plan for this.
Add the Logic App parameters we will need
Open the parameter editor and add all these parameters. They're all strings.
Parameter name | Default Value Description |
---|---|
ldEnvironmentKey | your environment e.g. production |
ldFeatureKey | the feature e.g. my-third-party-service |
ldProjectKey | the project e.g. my-project |
ldUserKey | user id e.g. [email protected] |
scheduleStart | local time e.g. 2021-04-29T19:30:00 |
scheduleEnd | local time e.g. 2021-04-29T19:30:00 |
The lsUserKey is used to test if our trigger has actually set the variant off for a user.
Add the recurrence trigger
This app will check if an update is required every 30 minutes. Logic apps provide a super easy to use recurrence trigger for this kind of thing.
{
"triggers": {
"Recurrence_Trigger": {
"recurrence": {
"frequency": "Minute",
"interval": 30
},
"type": "Recurrence"
}
}
Get Launch Darkly API key from Azure Key Vault
For security we store the Launch Darkly API key in an existing Azure Key Vault. Create a new Key Vault if you need it and then add the connection from Logic App action for reading Key Vault secrets.
Get the current status of the Launch Darkly feature
We need to make an http call to the launch darkly api. We use parameters to create the url and we add an authorization header from the Azure Key Vault secret.
"GET_current_feature_status": {
"inputs": {
"headers": {
"Authorization": "@body('Get_Launch_Darkly_API_key_secret')?['value']"
},
"method": "GET",
"uri": "https://app.launchdarkly.com/api/v2/users/@{parameters('ldProjectKey')}/@{parameters('ldEnvironmentKey')}/@{parameters('ldUserKey')}/flags/@{parameters('ldFeatureKey')} "
},
"runAfter": {
"Get_Launch_Darkly_API_key_secret": ["Succeeded"]
},
"type": "Http"
},
Parse the JSON http response
The http call action doesn't parse the response. We have to use the JSON parser Logic App action to perform parsing. All of the properties will be available in later steps.
You need an example of the response to have the parser create a JSON schema for you. You can use postman or curl to request one time from launch darkly.
"Parse_LD_Response_Body": {
"inputs": {
"content": "@body('GET_current_feature_status')",
"schema": {
"properties": {
"_links": {
"properties": {
"self": {
"properties": {
"href": {
"type": "string"
},
"type": {
"type": "string"
}
},
"type": "object"
}
},
"type": "object"
},
"_value": {
"type": "boolean"
},
"setting": {}
},
"type": "object"
}
},
"runAfter": {
"GET_current_feature_status": ["Succeeded"]
},
"type": "ParseJson"
}
},
Convert the Start and End times to booleans
This is the same thing repeated twice so I'll just describe scheduleStart. scheduleEnd is the same pattern with different names!
To start use a time zone conversion to UTC because all the other Logic App actions use UTC.
Initialise a boolean variable
This will hold the result from testing if the scheduled time has passed.
Test if the scheduled time has passed
Here we check if Now is greater than the scheduled time.
"Detect_if_start_time_has_passed": {
"inputs": {
"name": "isScheduledStartTimePassed",
"value": "@greater(ticks(utcNow()),ticks(body('Convert_schedule_start_to_UTC_time_zone')))"
},
"runAfter": {
"Initialize_start_time_variable": ["Succeeded"]
},
"type": "SetVariable"
},
Now do the same three steps except use the END schedule parameter.
Check if we're within the scheduled period
So check if we're after the start time and before the end time. We can do this based on the previous variables we created. See the screenshot for how this is configured.
If we ARE in schedule check if the feature is currently on
The _value
parameter here is from the Parse JSON action we did way up at the start. This is the state of the Launch Darkly feature right now.
If we are in schedule and it is currently ON (true) then we need to turn it OFF!
Call the Launch Darkly API to turn off the feature
Here again we use parameters from the Logic App to generate the url. We use the authorization from Key Vault. There is an additional content type here because Launch Darkly's api uses a format of JSON patch.
The body has some required parameters, the key we want to change, the specific instruction to Launch Darkly and a comment to log what we're doing for auditing.
Get the latest feature state to verify our call worked
We get state of the key again to make sure we actually toggled the feature as expected.
Parse this response from Launch Darkly
This is the same as the previous parsing step! We want to have the _value
available later.
Send a message to slack channel
Where I work we use slack for all comms so we use the built in action to send a message to notify the team that the feature has been toggled.
You'll need to be an administrator on your slack to add the integration.
Once the integration is added you can set it to "send a message to channel".
The more info you add here the better imho! You can change the icon and bot name but using the "Add new parameter" selection drop down.
Now do the case where we're outside of schedule and feature is OFF
This means we need to turn the feature on. You can see in the full Logic App diagram screenshots below how this looks in my app. It's very repetetive so I won't go through every step again but you're turning ON the feature this time and your message to slack should reflect that.
Here you can see the instruction is "turnFlagOn".
Test it out!
Set your schedule parameters to some time close to current time. Close the parameters entry by clicking the x on the top right.
Then click Save on the top left and click Run!
Conclusion
This was super easy to setup! I'm going to try to move more devops type work into logic apps for sure.
There are some improvements that could be made here. The turn on / turn off steps and slack messaging could be changed to use variables in a better way and then only having one instance of each.
The other thing that would be great is to have the app read a website or api to get the schedules from the third party. Right now we have to manually set the schedules every time.
The full logic app code view
{
"definition": {
"$schema": "https://schema.management.azure.com/providers/Microsoft.Logic/schemas/2016-06-01/workflowdefinition.json#",
"actions": {
"Convert_schedule_end_to_UTC_time_zone": {
"inputs": {
"baseTime": "@parameters('scheduleEnd')",
"destinationTimeZone": "UTC",
"formatString": "o",
"sourceTimeZone": "AUS Eastern Standard Time"
},
"kind": "ConvertTimeZone",
"runAfter": {
"Detect_if_start_time_has_passed": ["Succeeded"]
},
"type": "Expression"
},
"Convert_schedule_start_to_UTC_time_zone": {
"inputs": {
"baseTime": "@parameters('scheduleStart')",
"destinationTimeZone": "UTC",
"formatString": "o",
"sourceTimeZone": "AUS Eastern Standard Time"
},
"kind": "ConvertTimeZone",
"runAfter": {
"Parse_LD_Response_Body": ["Succeeded"]
},
"type": "Expression"
},
"Detect_if_end_time_has_passed": {
"inputs": {
"name": "isScheduledEndTimePassed",
"value": "@greater(ticks(utcNow()),ticks(body('Convert_schedule_end_to_UTC_time_zone')))"
},
"runAfter": {
"Initialize_end_time_variable": ["Succeeded"]
},
"type": "SetVariable"
},
"Detect_if_start_time_has_passed": {
"inputs": {
"name": "isScheduledStartTimePassed",
"value": "@greater(ticks(utcNow()),ticks(body('Convert_schedule_start_to_UTC_time_zone')))"
},
"runAfter": {
"Initialize_start_time_variable": ["Succeeded"]
},
"type": "SetVariable"
},
"GET_current_feature_status": {
"inputs": {
"headers": {
"Authorization": "@body('Get_Launch_Darkly_API_key_secret')?['value']"
},
"method": "GET",
"uri": "https://app.launchdarkly.com/api/v2/users/@{parameters('ldProjectKey')}/@{parameters('ldEnvironmentKey')}/@{parameters('ldUserKey')}/flags/@{parameters('ldFeatureKey')} "
},
"runAfter": {
"Get_Launch_Darkly_API_key_secret": ["Succeeded"]
},
"type": "Http"
},
"Get_Launch_Darkly_API_key_secret": {
"inputs": {
"host": {
"connection": {
"name": "@parameters('$connections')['keyvault']['connectionId']"
}
},
"method": "get",
"path": "/secrets/@{encodeURIComponent('launchDarklyApiWriteKey')}/value"
},
"runAfter": {},
"type": "ApiConnection"
},
"Initialize_end_time_variable": {
"inputs": {
"variables": [
{
"name": "isScheduledEndTimePassed",
"type": "boolean",
"value": false
}
]
},
"runAfter": {
"Convert_schedule_end_to_UTC_time_zone": ["Succeeded"]
},
"type": "InitializeVariable"
},
"Initialize_start_time_variable": {
"inputs": {
"variables": [
{
"name": "isScheduledStartTimePassed",
"type": "boolean",
"value": false
}
]
},
"runAfter": {
"Convert_schedule_start_to_UTC_time_zone": ["Succeeded"]
},
"type": "InitializeVariable"
},
"Is_current_time_within_desired_OFF_schedule": {
"actions": {
"Is_feature_turned_on": {
"actions": {
"GET_after_off_feature_status": {
"inputs": {
"headers": {
"Authorization": "@body('Get_Launch_Darkly_API_key_secret')?['value']"
},
"method": "GET",
"uri": "https://app.launchdarkly.com/api/v2/users/@{parameters('ldProjectKey')}/@{parameters('ldEnvironmentKey')}/@{parameters('ldUserKey')}/flags/@{parameters('ldFeatureKey')} "
},
"runAfter": {
"Turn_LD_feature_OFF": ["Succeeded"]
},
"type": "Http"
},
"Parse_after_off_response": {
"inputs": {
"content": "@body('GET_after_off_feature_status')",
"schema": {
"properties": {
"_links": {
"properties": {
"self": {
"properties": {
"href": {
"type": "string"
},
"type": {
"type": "string"
}
},
"type": "object"
}
},
"type": "object"
},
"_value": {
"type": "boolean"
},
"setting": {}
},
"type": "object"
}
},
"runAfter": {
"GET_after_off_feature_status": ["Succeeded"]
},
"type": "ParseJson"
},
"Post_message_(V2)": {
"inputs": {
"body": {
"channel": "your-development-channel",
"icon_emoji": ":red_circle:",
"text": "Turned OFF @{parameters('ldFeatureKey')} on [env: @{parameters('ldEnvironmentKey')}, project: @{parameters('ldProjectKey')}] for schedule (@{parameters('scheduleStart')} --> @{parameters('scheduleEnd')}) - test retreived variation result: @{body('Parse_after_off_response')?['_value']}",
"username": "DanBot"
},
"host": {
"connection": {
"name": "@parameters('$connections')['slack']['connectionId']"
}
},
"method": "post",
"path": "/v2/chat.postMessage"
},
"runAfter": {
"Parse_after_off_response": ["Succeeded"]
},
"type": "ApiConnection"
},
"Turn_LD_feature_OFF": {
"inputs": {
"body": {
"comment": "set state OFF using logic app",
"environmentKey": "@{parameters('ldEnvironmentKey')}",
"instructions": [
{
"kind": "turnFlagOff"
}
]
},
"headers": {
"Authorization": "@body('Get_Launch_Darkly_API_key_secret')?['value']",
"Content-Type": "application/json; domain-model=launchdarkly.semanticpatch"
},
"method": "PATCH",
"uri": "https://app.launchdarkly.com/api/v2/flags/@{parameters('ldProjectKey')}/@{parameters('ldFeatureKey')}"
},
"runAfter": {},
"type": "Http"
}
},
"expression": {
"and": [
{
"equals": [
"@body('Parse_LD_Response_Body')?['_value']",
"@true"
]
}
]
},
"runAfter": {},
"type": "If"
}
},
"else": {
"actions": {
"Is_feature_turned_off": {
"actions": {
"GET_after_on_feature_status": {
"inputs": {
"headers": {
"Authorization": "@body('Get_Launch_Darkly_API_key_secret')?['value']"
},
"method": "GET",
"uri": "https://app.launchdarkly.com/api/v2/users/@{parameters('ldProjectKey')}/@{parameters('ldEnvironmentKey')}/@{parameters('ldUserKey')}/flags/@{parameters('ldFeatureKey')} "
},
"runAfter": {
"Turn_LD_feature_ON": ["Succeeded"]
},
"type": "Http"
},
"Parse_after_on": {
"inputs": {
"content": "@body('GET_after_on_feature_status')",
"schema": {
"properties": {
"_links": {
"properties": {
"self": {
"properties": {
"href": {
"type": "string"
},
"type": {
"type": "string"
}
},
"type": "object"
}
},
"type": "object"
},
"_value": {
"type": "boolean"
},
"setting": {}
},
"type": "object"
}
},
"runAfter": {
"GET_after_on_feature_status": ["Succeeded"]
},
"type": "ParseJson"
},
"Post_message_(V2)_2": {
"inputs": {
"body": {
"channel": "your-development-channel",
"icon_emoji": ":green_heart:",
"text": "Turned ON @{parameters('ldFeatureKey')} on [env: @{parameters('ldEnvironmentKey')}, project: @{parameters('ldProjectKey')}] for schedule (@{parameters('scheduleStart')} --> @{parameters('scheduleEnd')}) - test retrieved variation result: @{body('Parse_after_on')?['_value']}",
"username": "DanBot"
},
"host": {
"connection": {
"name": "@parameters('$connections')['slack']['connectionId']"
}
},
"method": "post",
"path": "/v2/chat.postMessage"
},
"runAfter": {
"Parse_after_on": ["Succeeded"]
},
"type": "ApiConnection"
},
"Turn_LD_feature_ON": {
"inputs": {
"body": {
"comment": "set state ON using logic app",
"environmentKey": "@{parameters('ldEnvironmentKey')}",
"instructions": [
{
"kind": "turnFlagOn"
}
]
},
"headers": {
"Authorization": "@body('Get_Launch_Darkly_API_key_secret')?['value']",
"Content-Type": "application/json; domain-model=launchdarkly.semanticpatch"
},
"method": "PATCH",
"uri": "https://app.launchdarkly.com/api/v2/flags/@{parameters('ldProjectKey')}/@{parameters('ldFeatureKey')}"
},
"runAfter": {},
"type": "Http"
}
},
"expression": {
"and": [
{
"equals": [
"@body('Parse_LD_Response_Body')?['_value']",
"@false"
]
}
]
},
"runAfter": {},
"type": "If"
}
}
},
"expression": {
"and": [
{
"equals": ["@variables('isScheduledStartTimePassed')", "@true"]
},
{
"equals": ["@variables('isScheduledEndTimePassed')", "@false"]
}
]
},
"runAfter": {
"Detect_if_end_time_has_passed": ["Succeeded"]
},
"type": "If"
},
"Parse_LD_Response_Body": {
"inputs": {
"content": "@body('GET_current_feature_status')",
"schema": {
"properties": {
"_links": {
"properties": {
"self": {
"properties": {
"href": {
"type": "string"
},
"type": {
"type": "string"
}
},
"type": "object"
}
},
"type": "object"
},
"_value": {
"type": "boolean"
},
"setting": {}
},
"type": "object"
}
},
"runAfter": {
"GET_current_feature_status": ["Succeeded"]
},
"type": "ParseJson"
}
},
"contentVersion": "1.0.0.0",
"outputs": {},
"parameters": {
"$connections": {
"defaultValue": {},
"type": "Object"
},
"ldEnvironmentKey": {
"defaultValue": "production",
"type": "String"
},
"ldFeatureKey": {
"defaultValue": "my-third-party-service",
"type": "String"
},
"ldProjectKey": {
"defaultValue": "my-project",
"type": "String"
},
"ldUserKey": {
"defaultValue": "[email protected]",
"type": "String"
},
"scheduleEnd": {
"defaultValue": "2021-04-30T06:00:00",
"type": "String"
},
"scheduleStart": {
"defaultValue": "2021-04-29T19:30:00",
"type": "String"
}
},
"triggers": {
"Recurrence_Trigger": {
"recurrence": {
"frequency": "Minute",
"interval": 30
},
"type": "Recurrence"
}
}
},
"parameters": {
"$connections": {
"value": {
"keyvault": {
"connectionId": "<YOUR_CONNECTION>/providers/Microsoft.Web/connections/keyvault",
"connectionName": "keyvault",
"id": "<YOUR_CONNECTION>/managedApis/keyvault"
},
"slack": {
"connectionId": "<YOUR_CONNECTION>/providers/Microsoft.Web/connections/slack",
"connectionName": "slack",
"id": "<YOUR_CONNECTION>/managedApis/slack"
}
}
}
}
}