Show HN: Hypertune – Visual, functional, statically-typed configuration language https://ift.tt/SqI5jzA
May 04, 2023
0
Show HN: Hypertune – Visual, functional, statically-typed configuration language Hey HN! I'm Miraan, the founder at Hypertune, and I'm excited to be posting this on HN. Hypertune lets you make your code configurable to let teammates like PMs and marketers quickly change feature flags, in-app copy, pricing plans, etc. It's like a CMS but instead of only letting you set static content, you can insert arbitrary logic from the UI, including A/B tests and ML "loops". I previously built a landing page optimization tool that let marketers define variants of their headline, CTA, cover image, etc, then used a genetic algorithm to find the best combination of them. They used my Chrome extension to define changes on DOM elements based on their unique CSS selector. But this broke when the underlying page changed and didn't work with sites that used CSS modules. Developers hated it. I took a step back. The problem I was trying to solve was making the page configurable by marketers in a way that developers liked. I decided to solve it from first principles and this led to Hypertune. Here's how it works. You define a strongly typed configuration schema in GraphQL, e.g. type Query { page(language: Language!, deviceType: DeviceType!): Page! } type Page { headline: String! imageUrl: String! showPromotion: Boolean! benefits: [String!]! } enum Language { English, French, Spanish } enum DeviceType { Desktop, Mobile, Tablet } Then marketers can configure these fields from the UI using our visual, functional, statically-typed language. The language UI is type-directed so we only show expression options that satisfy the required type of the hole in the logic tree. So for the "headline" field, you can insert a String expression or an If / Else expression that returns a String. If you insert the latter, more holes appear. This means marketers don't need to know any syntax and can't get into invalid states. They can use arguments you define in the schema like "language" and "deviceType", and drop A/B tests and contextual multi-armed bandits anywhere in their logic. We overlay live counts on the logic tree UI so they can see how often different branches are called. You get the config via our SDK which fetches your logic tree once on initialization (from our CDN) then evaluates it locally so you can get flags or content with different arguments (e.g. for different users) immediately with no network latency. So you can use the SDK on your backend without adding extra latency to every request, or on the frontend without blocking renders. The SDK includes a command line tool that auto-generates code for end-to-end type-safety based on your schema. You can also query your config via the GraphQL API. If you use the SDK, you can also embed a build-time snapshot of your logic tree in your app bundle. The SDK initializes from this instantly then fetches the latest logic from the server. So it'll still work in the unlikely event the CDN is down. And on the frontend, you can evaluate flags, content, A/B tests, personalization logic, etc, instantly on page load without any network latency, which makes it compatible with static Jamstack sites. I started building this for landing pages but realized it could be used for configuring feature flags, in-app content, translations, onboarding flows, permissions, rules, limits, magic numbers, pricing plans, backend services, cron jobs, etc, as it's all just "code configuration". This configuration is usually hardcoded, sprawled across json or yaml files, or in separate platforms for feature flags, content management, A/B testing, pricing plans, etc. So if a PM wants to A/B test new onboarding content, they need a developer to write glue code that stitches their A/B testing tool with their CMS for that specific test, then wait for a code deployment. And at that point, it may not be worth the effort. The general problem with having separate platforms is that all this configuration naturally overlaps. Feature flags and content management overlap with A/B testing and analytics. Pricing plans overlap with feature flags. Keeping them separate leads to inflexibility and duplication and requires hacky glue code, which defeats the purpose of configuration. I think the solution is a flexible, type-safe code configuration platform with a strongly typed schema, type-safe SDKs and APIs, and a visual, functional, statically-typed language with analytics, A/B testing and ML built in. I think this solves the problem with having separate platforms, but also results in a better solution for individual use cases and makes new use cases possible. For example, compared specifically to other feature flag platforms, you get auto-generated type-safe code to catch flag typos and errors at compile-time (instead of run-time), code completion and "find all references" in your IDE (no figuring out if a flag is in kebab-case or camelCase), type-safe enum flags you can exhaustively switch on, type-safe object and list flags, and a type-safe logic UI. You pass context arguments like userId, email, etc, in a type-safe way too with compiler errors if you miss or misspell one. To clean up a flag, you remove it from your query, re-run code generation and fix all the type errors to remove all references. The full programming language under the hood means there are no limits on your flag logic (you're not locked into basic disjunctive normal form). You can embed a build-time snapshot of your flag logic in your app bundle for guaranteed, instant initialization with no network latency (and keep this up to date with a commit webhook). And all your flags are versioned together in a single Git history for instant rollbacks to known good states (no figuring out what combination of flag changes caused an incident). There are other flexible configuration languages like Dhall (discussed here: https://ift.tt/ufjiZqN ), Jsonnet (discussed here: https://ift.tt/CZPyt90 ) and Cue (discussed here: https://ift.tt/aY29lLp ). But they lack a UI for nontechnical users, can't be updated at run-time and don't support analytics, A/B testing and ML. I was actually going to start with a basic language that had primitives (Boolean, Int, String), a Comparison expression and an If / Else. Then users could implement the logic for each field in the schema separately. But then I realized they might want to share logic for a group of fields at the object level, e.g. instead of repeating "if (deviceType == Mobile) { primitiveA } else { primitiveB }" for each primitive field separately, they could have the logic once at the Page level: "if (deviceType == Mobile) { pageObjectA } else { pageObjectB }". I also needed to represent field arguments like "deviceType" in the language. And I realized users may want to define other variables to reuse bits of logic, like a specific "benefit" which appears in different variations of the "benefits" list. So at this point, it made sense to build a full, functional language with Object expressions (that have a type defined in the schema) and Function, Variable and Application expressions (to implement the lambda calculus). Then all the configuration can be represented as a single Object with the root Query type from the schema, e.g. Query { page: f({ deviceType }) => switch (true) { case (deviceType == DeviceType.Mobile) => Page { headline: f({}) => "Headline A" imageUrl: f({}) => "Image A" showPromotion: f({}) => true benefits: f({}) => ["Ben", "efits", "A"] } default => Page { headline: f({}) => "Headline B" imageUrl: f({}) => "Image B" showPromotion: f({}) => false benefits: f({}) => ["Ben", "efits", "B"] } } } So each schema field is implemented by a Function that takes a single Object parameter (a dictionary of field argument name => value). I needed to evaluate this logic tree given a GraphQL query that looks like: query { page(deviceType: Mobile) { headline showPromotion } } So I built an interpreter that recursively selects the queried parts of the logic tree, evaluating the Functions for each query field with the given arguments. It ignores fields that aren't in the query so the logic tree can grow large without affecting query performance. The interpreter is used by the SDK, to evaluate logic locally, and on our CDN edge server that hosts the GraphQL API. The response for the example above would be: { "__typename": "Query", "page": { "__typename": "Page", "headline": "Headline A", "showPromotion": true } } Developers were concerned about using the SDK on the frontend as it could leak sensitive configuration logic, like lists of user IDs, to the browser. To solve this, I modified the interpreter to support "partial evaluation". This is where it takes a GraphQL query that only provides some of the required field arguments and then partially evaluates the logic tree as much as possible. Any logic which can't be evaluated is left intact. The SDK can leverage this at initialization time by passing already known arguments (e.g. the user ID) in its initialization query so that sensitive logic (like lists of user IDs) are evaluated (and eliminated) on the server. The rest of the logic is evaluated locally by the SDK when client code calls its methods with the remaining arguments. This also minimizes the payload size sent to the client and means less logic needs to be evaluated locally, which improves both page load and render performance. The interpreter also keeps a count of expression evaluations as well as events for A/B tests and ML loops, which are flushed back to Hypertune in the background to overlay live analytics on the logic tree UI. It's been a challenge to build a simple UI given there's a full functional language under the hood. For example, I needed to build a way for users to convert any expression into a variable in one click. Under the hood, to make expression X a variable, we wrap the parent of X in a Function that takes a single parameter, then wrap that Function in an Application that passes X as an argument. Then we replace X in the Function body with a reference to the parameter. So we go from: if (X) { Y } else { Z } to ((paramX) => if (paramX) { Y } else { Z } )(X) So a variable is just an Application argument that can be referenced in the called Function's body. And once we have a variable, we can reference it in more than one place in the Function body. To undo this, users can "drop" a variable in one click which replaces all its references with a copy of its value. Converting X into a variable gets more tricky if the parent of X is a Function itself which defines parameters referenced inside of X. In this case, when we make X a variable, we lift it outside of this Function. But then it doesn't have access to the Function's parameters anymore. So we automatically convert X into a Function itself which takes the parameters it needs. Then we call this new Function where we originally had X, passing in the original parameters. There are more interesting details about how we lift variables to higher scopes in one click but that's for another post. Thanks for reading this far! I'm glad I got to share Hypertune with you. I'm curious about what use case appeals to you the most. Is it type-safe feature flags, in-app content management, A/B testing static Jamstack sites, managing permissions, pricing plans or something else? Please let me know any thoughts or questions! https://ift.tt/rO3x0nI May 4, 2023 at 08:31PM
Tags