Given an access log, write a utility that reports request count, error rate and p95 latency per endpoint.
Group lines by a normalised path so that /orders/1 and /orders/2 collapse to one endpoint, compute the percentile by nearest rank on the sorted latencies, and count the lines you could not parse instead of silently dropping them.
What the interviewer is scoring
- Whether the path is normalised before grouping, or every order id becomes its own endpoint
- Does the candidate state a percentile definition rather than assuming there is only one
- That unparseable input is counted and surfaced rather than skipped in silence
- Whether the parser is given a stream or reader instead of a filename, so it can be tested without files
- Does the candidate's own test suite have one reason to fail per test, or one test that asserts everything
Answer
The problem as stated
You are handed a log where each line looks like this, with a timestamp, method, path, status code, and duration in milliseconds:
2026-07-12T09:14:22Z GET /orders/8831 200 143
2026-07-12T09:14:22Z GET /orders/8832 500 27
2026-07-12T09:14:23Z POST /orders 201 402
Produce, per endpoint, the number of requests, the proportion that returned 5xx, and the 95th percentile duration. That is the whole brief, and the first useful thing to do is not to start typing.
Three questions to ask before writing code
Ask them out loud. Each one is a decision the interviewer has deliberately left out, and taking a decision silently is the same as guessing.
Is /orders/8831 the same endpoint as /orders/8832? Almost always yes, and if you group on the raw path you will produce a report with fifty thousand endpoints of one request each, which is useless. So you need to normalise variable path segments into a placeholder. Say what your rule is, because it is a heuristic and it will be wrong sometimes: a purely numeric segment is safe to collapse, a segment that could be either an identifier or a resource name is not.
What does p95 mean here? There is more than one definition. Nearest-rank on the sorted samples is the one to use for a log summary, because every value it reports is a value that actually occurred. Interpolating between samples invents a number no request experienced. Whichever you pick, state it, because the interviewer will otherwise assume you did not know there was a choice.
What do I do with a line that does not match? This is the important one and it is covered below.
The implementation
public final class LogSummary {
public record Stats(String endpoint, int count, int serverErrors, long p95Millis) {
double errorRate() { return count == 0 ? 0 : (double) serverErrors / count; }
}
public record Result(List<Stats> stats, int unparseableLines) {}
// Takes a Reader, not a File path: the unit test can pass a StringReader.
public static Result summarise(BufferedReader in) throws IOException {
Map<String, List<Long>> durations = new HashMap<>();
Map<String, Integer> errors = new HashMap<>();
int bad = 0;
String line;
while ((line = in.readLine()) != null) {
if (line.isBlank()) continue;
String[] f = line.split(" ");
if (f.length != 5) { bad++; continue; }
long millis;
int status;
try {
status = Integer.parseInt(f[3]);
millis = Long.parseLong(f[4]);
} catch (NumberFormatException e) {
bad++;
continue;
}
String endpoint = f[1] + " " + normalise(f[2]);
durations.computeIfAbsent(endpoint, k -> new ArrayList<>()).add(millis);
if (status >= 500) errors.merge(endpoint, 1, Integer::sum);
}
List<Stats> out = new ArrayList<>();
durations.forEach((endpoint, samples) -> out.add(new Stats(
endpoint, samples.size(), errors.getOrDefault(endpoint, 0), percentile(samples, 95))));
out.sort(Comparator.comparingLong(Stats::p95Millis).reversed());
return new Result(out, bad);
}
/** Collapses purely numeric segments: /orders/8831/items -> /orders/{id}/items */
static String normalise(String path) {
String[] segments = path.split("/", -1);
for (int i = 0; i < segments.length; i++) {
if (!segments[i].isEmpty() && segments[i].chars().allMatch(Character::isDigit)) {
segments[i] = "{id}";
}
}
return String.join("/", segments);
}
/** Nearest-rank: the smallest sample at or above the requested rank. Never interpolates. */
static long percentile(List<Long> samples, int p) {
List<Long> sorted = samples.stream().sorted().toList();
int rank = (int) Math.ceil(p / 100.0 * sorted.size()); // 1-based rank
return sorted.get(Math.max(0, rank - 1));
}
}
The two design choices worth defending are visible in the signature. summarise takes a BufferedReader rather than a path, so the test does not need a fixture file on disk and the same code works on stdin or an HTTP response body. And unparseableLines is part of the return type rather than a log statement, which forces the caller to see it.
What the interviewer grades in the test code
For an SDET the production code is roughly half the assessment. The tests are the writing sample for the job you are being hired to do, and they are read differently from application code.
One reason to fail per test. A single testSummarise that asserts the count, the error rate, the percentile, and the ordering tells you almost nothing when it goes red, because the first failed assertion hides the other three. Separate tests named after the behaviour — groupsNumericPathSegmentsTogether, countsOnlyFiveHundredsAsErrors, reportsUnparseableLineCount — turn a failure into a diagnosis before you have opened the file.
No logic in the test. A loop or a conditional in a test is untested code sitting inside your safety net, and a test that computes its own expected value by reimplementing the algorithm will agree with a wrong implementation. Expected values should be literals a human worked out.
Arrange, act, assert, visibly. Build the input, make one call, assert. Test methods that interleave setup and assertions are the ones that later grow a second act phase and stop testing what their name claims.
Determinism and independence. No sleeps, no dependence on the clock or the default locale, no shared mutable fixture that makes test order matter. split(" ") on a locale-independent format is safe here, but a parser that used Double.parseDouble on a decimal duration would be one comma away from failing only in CI.
Boundaries that actually bite. Empty input. A single sample, where p95 must return that sample and not blow up on rank - 1. A blank line. A line with the wrong field count. A status of exactly 500 and exactly 499. Those five cases find more real defects than twenty happy-path variations.
Failure messages that name the value. assertEquals(143, stats.p95Millis()) is fine because JUnit prints both sides; a bare assertTrue(stats.p95Millis() == 143) prints "expected true, got false" and wastes the next ten minutes.
@Test
void reportsTheOnlySampleAsP95WhenThereIsOneRequest() throws Exception {
var result = LogSummary.summarise(reader("2026-07-12T09:14:22Z GET /orders/1 200 143"));
assertEquals(143, result.stats().get(0).p95Millis());
}
@Test
void countsMalformedLinesInsteadOfDiscardingThem() throws Exception {
var result = LogSummary.summarise(reader("garbage", "2026-07-12T09:14:22Z GET /a 200 5"));
assertEquals(1, result.unparseableLines()); // the count is the point of the test
}
Silent skipping is the answer that loses the round
Every candidate writes the continue that skips a line which does not parse. The ones who pass write the counter next to it and return it.
The reasoning is specific to test tooling and worth saying explicitly: a utility that quietly drops eight percent of the log will confidently report a p95 that is wrong by an unknown amount, and nobody will ever question it because there is no evidence anything went missing. A test tool producing a plausible wrong number is more dangerous than one that throws, because a crash gets investigated and a number gets pasted into a release report. So parse errors have to be either counted and reported, or fatal — and which of the two it is should be a decision you offer the interviewer rather than one you make by omission.
Whether you count the lines you failed to parse is the cheapest possible test of whether you think about your own tooling the way you are paid to think about the product's.
Likely follow-ups
- The log is 40 GB. What changes in your implementation?
- How would you compute p95 without holding every latency in memory?
- Two of your endpoints are the same resource with different casing. Whose problem is that?
- How would you test the percentile calculation without duplicating the implementation in the test?
Related questions
- How do you model a realistic workload for a performance test?hardAlso on percentiles3 min
- A load test shows p99 latency climbing sharply past 500 concurrent users. How do you work out why?hardAlso on percentiles6 min
- Your p99 latency jumped tenfold after a deploy while p50 is unchanged. Diagnose it out loud.hardAlso on percentiles6 min
- How would you design a thread-safe component, and why is adding synchronized to every method not a design?hardSame kind of round: coding7 min
- How do you tell whether a value escapes to the heap, and how would you find the allocations that are costing you?hardSame kind of round: coding6 min
- Adding a second LEFT JOIN doubled the revenue figure on a report. Explain what happened and write the correct query.mediumSame kind of round: coding4 min
- How do you decide between BFS and DFS - and how do you recognise a graph problem that nobody described as a graph?mediumSame kind of round: coding4 min
- Given package weights in a fixed order, find the smallest ship capacity that delivers them all within D daysmediumSame kind of round: coding5 min