Locating LaunchDarkly Feature Flag References In Your Application Code In Lucee 5.3.2.77
At InVision, we've been using - and loving - LaunchDarkly feature flags for the last 3-years. Feature flags have completely changed the way that we approach application development. But, it's not all roses and unicorns. Feature flags also introduce a new type of technical debt which, left unchecked, will lead to critical code rot within the application. Ideally, all non-operational feature flags should be removed from the code once a feature is rolled-out. But, this rarely happens. Which means, the application becomes littered with obfuscated, misleading, and out-dated control-flows. As such, I wanted to put together a small Lucee 5.3.2.77 utility that would help me analyze my ColdFUsion and JavaScript code, shinning a light on which feature flags might be removed from the application.
The underpinnings of this Lucee CFML utility entail a fairly straight-forward, brute-force workflow:
- Look up feature flags in the remote LaunchDarkly API.
- Find reference to those feature flags in the local application code.
- Present the feature flag references to the user in a sortable manner.
At first, this seems like a ridiculous approach. But, then one remembers that computers are hella fast. And, we can sprinkle in caching to make the consumption of the data more enjoyable. With that said, this demo has only two files: the locator and the renderer. Let's look at the renderer first so we can see how this demo works.
The renderer for the demo takes the results of the feature flag locator and presents them to the user. It's unclear how the data will be interpreted; so, we're going to provide some sorting options:
- Sort by date of feature flag creation.
- Sort by count of references in the application code.
With these options, the user (ie, the developer) can examine the collection of feature flags using different perspectives. And then, hopefully, formulate a plan for removing rotting feature flags from the application.
Here's the renderer. Note that the actual search is being performed inside a CFML closure that uses the cachedWithin
function memoization feature. This way, the user can re-render the page using different sorting options without having to incur the relatively high cost of the search algorithm on every page refresh.
NOTE: My LaunchDarkly credentials are being read from a different file for security purposes. They live in a simple JSON file for this demo.
<cfscript>
param name="url.sortOn" type="string" default="createdAt";
param name="url.sortDir" type="numeric" default="-1";
// ------------------------------------------------------------------------------- //
// ------------------------------------------------------------------------------- //
credentials = deserializeJson( fileRead( "./credentials.json" ) );
// NOTE: Since scouring the file system is a SLOW PROCESS, we're going to wrap the
// call in a Closure that uses process caching. This way, we can refresh this page
// to examine the feature flag usage without having to re-process the search each and
// every time.
// --
// This Immediately Invoked Function Expression (IIFE) uses a static string argument
// in order to bust the cache as needed during the algorithm development process.
results = (
function() cachedWithin = createTimeSpan( 0, 1, 0, 0 ) {
var locator = new FeatureFlagLocator(
launchDarklyAccessToken = credentials.accessToken,
launchDarklyProjectKey = credentials.projectKey,
launchDarklyEnvironmentKey = credentials.environmentKey
);
var results = locator.searchForFeatureFlags(
directories = [
expandPath( "../../assets/apps/" ),
expandPath( "../../cflibs/" ),
expandPath( "../../subsystems/" )
],
fileExtensions = "cfc,cfm,js"
);
return( results );
}
)( "cache-version: 1" );
sortedResults = results.sort(
( a, b ) => {
var aValue = a[ url.sortOn ];
var bValue = b[ url.sortOn ];
if ( aValue == bValue ) {
return( 0 );
}
return( ( aValue < bValue ) ? url.sortDir : -url.sortDir );
}
);
</cfscript>
<cfoutput>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>
LaunchDarkly Feature Flag Locator
</title>
<style type="text/css">
body {
font-size: 18px ;
}
</style>
</head>
<body>
<h1>
LaunchDarkly Feature Flag Locator
</h1>
<p>
<strong>Sort By:</strong>
<a href="#cgi.script_name#?sortOn=createdAt&sortDir=-1">Date-ASC</a>
<a href="#cgi.script_name#?sortOn=createdAt&sortDir=1">Date-DESC</a>
—
<a href="#cgi.script_name#?sortOn=codeCount&sortDir=-1">References-ASC</a>
<a href="#cgi.script_name#?sortOn=codeCount&sortDir=1">References-DESC</a>
</p>
<h2>
Results
</h2>
<ul>
<cfloop index="item" array="#sortedResults#">
<!---
Since feature flag keys represent some degree of internal information,
let's obfuscate the key for the demo.
--->
<cfset keyish = ( item.key.left( 5 ) & "*".repeatString( item.key.len() ) ) />
<li>
<strong>#encodeForHtml( keyish )#</strong>
created on
#item.createdAt.dateFormat( "yyyy-mm-dd" )#<br />
<cfloop index="filePath" array="#item.codeReferences#">
- #encodeForHtml( getFileFromPath( filePath ) )#<br />
</cfloop>
</li>
</cfloop>
</ul>
</body>
</html>
</cfoutput>
The renderer doesn't really do very much. It invokes the search; then, it lists each LaunchDarkly feature flag - by the desired sort - along with its date of creation and the collection of files that reference the feature flag key. If we run this Lucee CFML code, we get the following output:
As you can see, there are a number of LaunchDarkly feature flags that aren't referenced in the application code at all. Those should be removed / archived within LaunchDarkly in order to reduce noise. But, for those LaunchDarkly feature flags that are referenced, we can quickly see how many times they are references in the code. The fewer the references, the easier it should be (theoretically) to remove them. And, we can cross-reference those counts with the creation-date to help determine which feature flags are more likely to be irrelevant in the current application state.
ASIDE: In our case, some of those non-referenced feature flags are owned by different applications. That was a mistake that we made as a company - commingling feature flags from different applications in the same LaunchDarkly project context.
Now, let's look at the powerhouse of this demo - the feature flag locator. As I outlined above, the locator is just a brute-force iteration over the file-system looking for feature flag references. It exposes one public method, searchForFeatureFlags()
, which takes a list of directories and file-extensions to search. Internally, it's quite procedural in nature:
component
output = false
hint = "I help find LaunchDarkly feature flag references in the code."
{
/**
* I initialize the feature flag locator with the given LaunchDarkly credentials.
*
* @launchDarklyAccessToken I am the provisioned API key for this task (should be READ ONLY).
* @launchDarklyProjectKey I am the project key in which to search.
* @launchDarklyEnvironmentKey I am the environment in which to search.
*/
public any function init(
required string launchDarklyAccessToken,
required string launchDarklyProjectKey,
required string launchDarklyEnvironmentKey
) {
variables.launchDarklyAccessToken = arguments.launchDarklyAccessToken;
variables.launchDarklyProjectKey = arguments.launchDarklyProjectKey;
variables.launchDarklyEnvironmentKey = arguments.launchDarklyEnvironmentKey;
return( this );
}
// ---
// PUBLIC METHODS.
// ---
/**
* I search for LaunchDarkly feature flag references in the given directory, filtering
* on the given file extensions list. Returns an array of structs with the following
* keys:
*
* - name
* - key
* - createdAt
* - codeReferences
* - codeCount
*
* @directories I am the directories in which to search files.
* @fileExtensions I am the comma-delimited list of extensions on which to filter (inclusive).
*/
public array function searchForFeatureFlags(
required array directories,
required string fileExtensions
) {
var flags = getLaunchDarklyFeatureFlags();
var pattern = createRegexPattern( flags );
var filePaths = getFilePaths( directories, fileExtensions );
// The results for this method is an augmented collection of feature flags that
// also contains the list of files in which each feature flag was referenced.
var results = flags.map(
( flag ) => {
return({
name: flag.name,
key: flag.key,
createdAt: flag.createdAt,
// This will represent the places within the code that the feature
// flag was referenced (file paths).
codeReferences: [],
codeCount: 0 // Easier for sorting in the calling context.
});
}
);
// As we match the RegEx pattern, we need a way to quickly look up a result based
// on the matching feature flag key. As such, let's index the results by key.
// This will allow us to locate the desired result item without having to search
// through the entire collection.
var resultsIndex = groupBy( results, "key" );
// Now that we have our feature flag pattern and our file paths, we're just going
// to brute-force iterate over the file system, reading in files, and trying to
// locate key-references.
for ( var filePath in filePaths ) {
// Some directories are named like file-paths. Skip those devious wonks.
if ( directoryExists( filePath ) ) {
continue;
}
var matcher = pattern.matcher( fileRead( filePath ) );
// Iterate over each key-match in the given file.
while ( matcher.find() ) {
var matchedKey = matcher.group( 2 );
// Record the matching file path in the results item.
resultsIndex[ matchedKey ].codeReferences.append( filePath );
resultsIndex[ matchedKey ].codeCount++;
}
}
return( results );
}
// ---
// PRIVATE METHODS.
// ---
/**
* I return a Java RegEx Pattern based on the given set of Feature Flags. The pattern
* requires the feature flag keys to be quoted (either double or single quotes).
*
* @featureFlags I am the remote LaunchDarkly feature flags.
*/
private any function createRegexPattern( required array featureFlags ) {
var keyList = featureFlags
.map(
( flag ) => {
return( flag.key );
}
)
.toList( "|" )
;
var patternText = "(['""])(#keyList#)\1";
return( createObject( "java", "java.util.regex.Pattern" ).compile( patternText ) );
}
/**
* I return a comprehensive list of files that live below the given directory. Only
* includes files with the matching extensions.
*
* @directories I am the directories to iterate.
* @fileExtensions I am a comma-delimited list of file extensions to filter on.
*/
private array function getFilePaths(
required string directories,
required string fileExtensions
) {
// Convert list of extensions to filter list, ex: "*.cfm|*.txt|*.js".
var filter = fileExtensions
.listToArray( "," )
.map(
( fileExtension ) => {
return( "*.#fileExtension#" );
}
)
.toList( "|" )
;
var results = [];
for ( var directory in directories ) {
var fileList = directoryList(
path = directory,
recurse = true,
listInfo = "path",
filter = filter
);
results.append( fileList, true );
}
return( results );
}
/**
* I retrieve the active (ie, non-archived) feature flags from the given LaunchDarkly
* environment. Returns an array of structs with the following keys:
*
* - name
* - key
* - createdAt
*/
private array function getLaunchDarklyFeatureFlags()
// NOTE: Since this is performing an HTTP call for data that is unlikely to
// change, we're going to cache it for an hour. This way, we can refresh the
// page over-and-over while developing this algorithm without incurring the
// network latency.
cachedWithin = createTimeSpan( 0, 1, 0, 0 )
{
var apiRequest = new Http(
method = "GET",
url = "https://app.launchdarkly.com/api/v2/flags/#launchDarklyProjectKey#/",
getAsBinary = "yes",
charset = "utf-8"
);
apiRequest.addParam(
type = "header",
name = "Authorization",
value = launchDarklyAccessToken
);
apiRequest.addParam(
type = "header",
name = "Content-Type",
value = "application/json"
);
apiRequest.addParam(
type = "url",
name = "env",
value = launchDarklyEnvironmentKey
);
var apiResponse = apiRequest.send().getPrefix();
var fileContent = isBinary( apiResponse.fileContent )
? charsetEncode( apiResponse.fileContent, "utf-8" )
: apiResponse.fileContent
;
// Validate a successful response code.
if ( ! reFind( "2\d\d", apiResponse.statusCode ) ) {
throw( type = "HttpRequestFailure" );
}
// Let's filter out any archived flags and normalize the response.
var flags = deserializeJson( fileContent )
.items
.filter(
( flag ) => {
// NOTE: Someone created some very poorly named flags.
if ( "test".listFind( flag.key ) ) {
return( false );
}
return( ! flag.archived );
}
)
.map(
( flag ) => {
return({
name: flag.name,
key: flag.key,
createdAt: createObject( "java", "java.util.Date" ).init( javaCast( "long", flag.creationDate ) )
});
}
)
;
return( flags );
}
/**
* I group the given collection by the given key / property.
*
* @collection I am the collection being grouped.
* @key I am the item key on which to base the grouping.
*/
private struct function groupBy(
required array collection,
required string key
) {
var index = {};
for ( var item in collection ) {
index[ item[ key ] ] = item;
}
return( index );
}
}
Like I said, very brute-force. We're literally gathering a list of files; then, for each file, we're using a Regular Expression Pattern to see which feature flags are referenced in the file content. This algorithm assumes that all references to the feature flag keys will live within a quoted-string. In our application, this makes sense because of our naming conventions; but, it may not be a viable solution for your application. You may need to tweak as needed.
We currently have over 600 feature flags in LaunchDarkly - like I said, it revolutionized the way we approach application development. But, of those 600-plus feature flags, a large portion of them are rotting away unnecessarily in our application code. And I, for one, can't stand it. So, I'm hoping that with this utility, I'll be able to start going through my ColdFusion application and removing LaunchDarkly feature flags that are no longer relevant.
Want to use code from this post? Check out the license.
Reader Comments
@All,
One of my fellow InVisioneers, Jason LeMoine, pointed out that LaunchDarkly does have a code-integration feature:
https://docs.launchdarkly.com/docs/git-code-references
From the docs:
It sounds really cool. But, we've never set it up, so I can't say whether it work well or not :D But, the idea is groovy.
Hey @Ben! Thanks, as always, for the LaunchDarkly love. :-)
My colleague Rich Manalang created a video demonstrating our Code References feature. The first two minutes explain what you already know about why this is useful, and then it gets into the demo: https://www.youtube.com/watch?v=9ZtfB8dDlpE
The documentation is here: https://docs.launchdarkly.com/integrations/code-references
... and you can find extra information about configuring the
ld-find-code-refs
tool (that scans your codebase) in the GitHub repo: https://github.com/launchdarkly/ld-find-code-refs/Note that while you can sort the flag list on the dashboard by flag age, it takes a couple more clicks to get to the code references page, so you may find it easier to stick with your existing UI. However, you can still use
ld-find-code-refs
in your CI pipeline, and have your UI pull data from our code references API: https://apidocs.launchdarkly.com/reference#code-references-overview-1If you find this useful, or if there are ways we can improve it, let me know. And thanks again for your awesome advocacy!
@Yoz,
Late to reply, but I just watched the demo video. It does look pretty cool! It's funny, I'm looking at my blog post here, and its' about a year old and I am shocked that we have over 600 feature flags. Well, a year later, looking at LD, I'm seeing that we have over 950 feature flags! Holy smokes! Though, to be fair, our team made the critical mistake of having a single "Project" for all the teams, so when I look at my project, I see everyone's feature flags. I really wish we could go back and undo that mistake.