Scheduling a feature toggle using no-code with Azure Logic Apps

Photo by Lorenzo Herrera

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.

  1. I need to implement this quickly and move on to product features
  2. It had to have built in scheduling
  3. It needed to be easily maintained by anyone
  4. It needed to have easy access to change the schedule by anyone (a GUI for parameters)
  5. 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!

Top half of app designer

Bottom half of app designer

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 nameDefault Value Description
ldEnvironmentKeyyour environment e.g. production
ldFeatureKeythe feature e.g. my-third-party-service
ldProjectKeythe project e.g. my-project
ldUserKeyuser id e.g. [email protected]
scheduleStartlocal time e.g. 2021-04-29T19:30:00
scheduleEndlocal 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.

Add the recurrence trigger

{
  "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 Launch Darkly API key

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 the current status

"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 the JSON http response

"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.

Convert to UTC

Initialise a boolean variable

This will hold the result from testing if the scheduled time has passed.

initialise a variable

Test if the scheduled time has passed

Here we check if Now is greater than the scheduled time.

Test if time has passed

      "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.

Are we within scheduled period

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!

Is the feature currently on

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.

Turn off the feature in Launch Darkly

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.

Get the feature status from Launch Darkly

Parse this response from Launch Darkly

This is the same as the previous parsing step! We want to have the _value available later.

Parse the response

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.

Send message to Slack

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".

Turn on the feature in Launch Darkly

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"
        }
      }
    }
  }
}