SaaS CMS has officially launched! Learn more now.

Henrik Fransas
Feb 25, 2015
(7 votes)

How to do custom query and click tracking with EPiServer Find

Custom query tracking

Sometimes it is necessary to do your own query and/or click tracking for EPiServer Find. One example is if you for example are doing many query’s at the same time for the search page. I have made an example of this with the Alloy project where the result looks like this (I know that this example could be solved with one query and facets but the example is simplified, in a real world each section/query would have it’s own facets).


As you can see the search for “remote locations appear” does results in one hit in the section for Products but not anywhere else. I have implemented the track() in each query like this:

public IEnumerable<SearchContentModel.SearchHit> SearchForProducts(string searchText, int maxResults) { var query = _client.Search<ProductPage>().For(searchText); var searchResults = query.Take(maxResults).Track().GetContentResult(); return searchResults.Items.Select(s => new SearchContentModel.SearchHit() { Title = s.Name, Url = s.LinkURL, Excerpt = s.Name }); }

This will create a little strange behaviour in the statistics part of the Find GUI where it could be like this:


As you can see, the query ends up in the statistics over queries without any hits but as we saw in the search result there were one hit.

This is not so strange because when doing this search we did actually four searches against EPiServer Find and for three of them there were no hits but for the fourth it was. This makes it hard on EPiServer Find, to be able to figure out it if should handle it like one query or many so we need to make it easier for Find to do it’s work.

First we need to remove the tracking part from the query so the same example as above looks like:

public IEnumerable<SearchContentModel.SearchHit> SearchForProducts(string searchText, int maxResults) { var query = _client.Search<ProductPage>().For(searchText); var searchResults = query.Take(maxResults).GetContentResult(); return searchResults.Items.Select(s => new SearchContentModel.SearchHit() { Title = s.Name, Url = s.LinkURL, Excerpt = s.Name }); }

After that we create a new function called TrackQuery that looks like this:

public void TrackQuery(string query, int nrOfHits, string id) { SearchClient.Instance.Statistics().TrackQuery(query, x => { x.Id = id; x.Tags = ServiceLocator.Current.GetInstance<IStatisticTagsHelper>().GetTags(false); x.Query.Hits = nrOfHits; }); }

In this function we use the function TrackQuery that exist in SearchClient.Instance.Statistics() that takes the string to query for as the first parameter and a System.Action<TrackQueryCommand> as a second parameter. To the command we pass in the id of the TrackContext, the statistic tags and the total number of hits that query resulted in.

Now we need to call this function and we do that from the SearchPageController like this:

model.TrackId = new TrackContext().Id; _ePiServerFindSearchService.TrackQuery(q, model.NumberOfHits, model.TrackId);

We create a new TrackContext and saves the Id of it in the ViewModel because we need it in the next part where we do custom click tracking. model.NumberOfHits is the total number of hits for all four queries. And now we got correct statistics, at least if you look from a visitor perspective because the visitor does not care how many queries it is against EPiServer Find, they only care about the complete result.

Custom click tracking

This part is a little more tricky but necessary in many solutions. The reason for that is that the built in click tracking in EPiServer Find only works with unified search, if you return a result with GetContentResult or with GetResult there will not be any click tracking by default. This is because EPiServer Find can’t be shore on where to hook up on the url that will be used to present the result. If you do a Unified Search and have all the right javascript loaded all your urls in the search result will look something like this:

The strange querystring is the Id of the TrackContext, Tags, Ip address of the client, and the result items id that contains of the type of the result and the id of the result.
To be able to get this to work on all our queries we need to do a couple of things. First we need the create a function that we can use to send our tracking request to EPiServer Find. It looks like this:

public void TrackClick(string query, string hitId, string trackId) { SearchClient.Instance.Statistics().TrackHit(query, hitId, command => { command.Hit.Id = hitId; command.Id = trackId; command.Ip = ""; command.Tags = ServiceLocator.Current.GetInstance<IStatisticTagsHelper>().GetTags(false); command.Hit.Position = null; }); }

The function TrackHit exist in the same place as TrackQuery and works pretty much the same but it also takes the id of the hit as a parameter and the TrackQueryCommand has a couple of more properties that needs to be set.
Command.Hit.Id is the id of the hit
Command.Id is the id of the TrackingContext (or any other way to identify that you used when registered the TrackQuery)
Command.Ip is the client ip adress. I think this is pretty unimportant because often it is only the ip adress of the load balancer
Command.Tags is the tags
Command.Hit.Position is the position in the result the hit was on. I set it to null here because it is not so interesting right now

To be able to use this function we need to extend our searchhit object to include the hitid and hittype and we need to set it on every search. We do it like this:

public IEnumerable<SearchContentModel.SearchHit> SearchForProducts(string searchText, int maxResults) { var query = _client.Search<ProductPage>().For(searchText); var searchResults = query.Take(maxResults).GetContentResult(); return searchResults.Items.Select(s => new SearchContentModel.SearchHit() { Title = s.Name, Url = s.LinkURL, Excerpt = s.Name, HitId = SearchClient.Instance.Conventions.IdConvention.GetId(s), HitType = SearchClient.Instance.Conventions.TypeNameConvention.GetTypeName(s.GetType()) }); }

Except for Unified Search where both id and type exists in the SearchHit, so that looks like this:

public IEnumerable<SearchContentModel.SearchHit> Search(string searchText, int maxResults) { var query = _client.UnifiedSearchFor(searchText) .Filter(f => !f.MatchType(typeof (ArticlePage))) .Filter(f => !f.MatchType(typeof(ProductPage))) .Filter(f => !f.MatchType(typeof(NewsPage))); var searchResults = query.Take(maxResults).GetResult(); return searchResults.Hits.Select(hit => new SearchContentModel.SearchHit() { Title = hit.Document.Title, Url = hit.Document.Url, Excerpt = hit.Document.Excerpt, HitId = hit.Id, HitType = hit.Type }); }

Now we got all the basics done, we need to make shore there will be a request on every click and for that we can use jQuery and AJAX, AngularJS, Knockout JS or anything else you like, I will do it the most simple way, with jQuery and AJAX.

First we create a WebApi controller like this:

using System; using System.Web.Mvc; using CustomClickTracking.Business; using EPiServer.ServiceLocation; namespace CustomClickTracking.Controllers.Ajax { public class ClickTrackingController : Controller { [HttpGet] public JsonResult Track(string query, string hitId, string trackId) { try { var searchService = ServiceLocator.Current.GetInstance<SearchService>(); searchService.TrackClick(query, hitId, trackId); return Json(new {msg = "Click tracked"}, JsonRequestBehavior.AllowGet); } catch (Exception e) { return Json(new {Success = false}, JsonRequestBehavior.AllowGet); } } } }

Next is the tricky part, we need to attach a click event on all links in the search result and we need to set the tracking id for the whole page, but the hitid for every hit/result. To do this we use the “new” data attribute and jQuery.

We start with adding a new div that wraps all the four list with results in it and then we give that div the id ResultListWrapper. Doing like that we can create a javascript function that attach the click event to all the a tags inside that div.

We also add a new attribute to each a tag with the hitid and that id should look like this [HitType]/[HitId] so a example of a a tag looks like this:¨

<a href="@hit.Url" data-hitid="@hit.HitType/@hit.HitId">@hit.Title</a>

If we do not have set up routing to be able to route to non-EPi pages we need to do that. The most simple way is to add a route that looks like this in global.asaxc (it might not be the best way, since it opens up for all requests)

protected override void RegisterRoutes(RouteCollection routes) { base.RegisterRoutes(routes); routes.MapRoute(name: "api", url: "api/{controller}/{action}", defaults: new { controller = "ClickTrackingController", action = "index" }); }

When all this is done we create the javascript function that will hock on the click event for anchors. It looks like this:

<script type="text/javascript"> $(function () { $("#ResultListWrapper a").click(function (e) { $.get("/api/ClickTracking/Track", { query: '@Html.Raw(Model.SearchedQuery)', hitId: $(this).data("hitid"), trackId: "@Model.TrackId" }); }); }); </script>

We place the function inside a $(function()… to be shore that it runs when the DOM is fully loaded and to hook on all anchors we write $(“#ResultListWrapper a”) and this means all a that exist anywhere below the div with id ResultListWrapper.

Inside the function we do a simple get on the WebApi controller and with it I send the query, the hitid and the trackid. Because I have this script inside my resultview I  have access to the model that has the property SearchQuery and TrackId on it (that is why we added TrackId to the model in previous step.

When all this is in place we both register queries with the right information and we get the clicks on it registered.


This has been tested with both EPiServer Find 8 and EPiServer Find 9 and you can find the complete source code here:

It should work out of the box if you download the complete solution, the only thing you have to do is create your own index on and update web.config with your information (I have deleted the index that are in the source codes history).

This is a quick example on how to do this and I hope it will help.

Feb 25, 2015


Feb 25, 2015 01:04 PM

This is a really useful post. The tracking and analytics features on FInd are awesome, and seem so easy to use (i.e. they work out of the box when using unified search). Its a shame it isn't so easy when using GetResult and GetContentResult type searches :(

The out-of-the-box tracking works a little differently than the click handler method you have here (see It parses each link, when clicked, removes the querystring and stores the tracking data in a cookie. The data from the cookie is read when the target page is loaded.

I was wondering whether you need the additional webAPI and client code here. Could you not just render the additional tracking querystring to each link then let the out-of-the-box Find client side implementation ensure the tracking works?

Henrik Fransas
Henrik Fransas Feb 25, 2015 01:34 PM

Thanks Mark!
You could probably do that, but I like this way better, it gives me total control over what happens and I am not depending on script that EPiServer Find injects to the page.

K Khan
K Khan Feb 25, 2015 03:49 PM

I will hug you for this post, if we ever met, Savior!

Henrik Fransas
Henrik Fransas Feb 25, 2015 03:54 PM

Khan hopefully we meet on the next EMVP summit, so I am looking forward to the hug :)

Peter Gustafsson
Peter Gustafsson Jun 8, 2015 09:56 AM

Great post Henrik, now here's a question.

Will SearchClient.Instance.Statistics().TrackQuery(query, x => { x.Id = id; x.Tags = ServiceLocator.Current.GetInstance<>IStatisticTagsHelper>().GetTags(); x.Query.Hits = nrOfHits; });

provide statistics that feeds the autocomplete feature (http://mysite,com/find_v2/_autocomplete?prefix=banana&size=3) ?

I'm currently using your implementation to track query statistics but not getting any autocomplete suggestions except editoral ones which I have added myself.

Is there perhaps a minimum amount of number of searches for a specific term that need to be fulfilled in order for it to be returned as a suggestion. I.e. 35 searches for "test" during the last 30 days might not be enough for it to be returned as a suggestion when I call http://mysite,com/find_v2/_autocomplete?prefix=tes&size=3

dada Oct 4, 2017 02:58 PM

Make sure you use




Will retrieve specific language ID AND the "all language" ID. This is not supported to send in. You only need to send in the specific language. 

Not doing like this will make the tracked query only appear under "All languages" in UI and not under the specific language.

Rui Vaz
Rui Vaz Jun 1, 2018 03:09 PM

Thanks for the post.

Managed to create the custom click tracking. I see that the clicks are being logged, but on the search without relevant hits tab, my click-through rate is 0% on all events.

Does this implementation take click-through into account?


Henrik Fransas
Henrik Fransas Jun 4, 2018 06:53 AM

Rui, it should do, but it has some years on it so it might be that somethings has changes

dada Aug 7, 2018 06:00 PM

Rui, there could be a problem how you implement trackId. We've seen this issue previously. Please contact us on for further troubleshooting.

Johnny Mullaney
Johnny Mullaney Nov 21, 2019 08:00 PM

Hi Henrik,

Thanks for the great post!

In our implementation we use GetResult() rather than GetContentResult() to retrieve the results which does the mapping to the view model.

Instead of having to call methods within the SearchClient.Instance.Conventions assembly, might you know if there is data i can map from ProductContent which can be used for HitId? I assume that i am simply do a typeof of the base product type?

Appreciate the help!


Nalin Nov 3, 2020 05:19 AM

Hi Henrik,

We have implemented the Click Tracking and encounted following error when clicks on a search phrase

And this is the way that we implemented TrackClick method

Could you please help us to fix the error that appear in the Find Statistics?

Our Epi versions are:

CMS - 11.10.5

Commerce - 12.9.1

Thank you.

Nalin Nov 22, 2020 09:36 PM

Hi All,

Sorry for te late response...

This was resolved, I restarted the Azure Site and it started to work as expected.

Thank you,


John Ligtenberg
John Ligtenberg Jan 19, 2021 08:56 AM


I've been using this post to get DidYouMean results with a typed search.

This only worked after I changed 

_client.Statistics().DidYouMean(query, 10);


_client.Statistics().GetDidYouMean(query, x => x.Size = 10);

sheider Feb 1, 2021 10:54 PM

Hey guys,

Just thought I would post the link to the epi docs for some of the apis in the example are updated with the newer versions!

Please login to comment.
Latest blogs
Frontend Hosting for SaaS CMS Solutions

Introduction Now that CMS SaaS Core has gone into general availability, it is a good time to start discussing where to host the head. SaaS Core is...

Minesh Shah (Netcel) | Jul 20, 2024

Optimizely London Dev Meetup 11th July 2024

On 11th July 2024 in London Niteco and Netcel along with Optimizely ran the London Developer meetup. There was an great agenda of talks that we put...

Scott Reed | Jul 19, 2024

Optimizely release SaaS CMS

Discover the future of content management with Optimizely SaaS CMS. Enjoy seamless updates, reduced costs, and enhanced flexibility for developers...

Andy Blyth | Jul 17, 2024 | Syndicated blog

A day in the life of an Optimizely Developer - London Meetup 2024

Hello and welcome to another instalment of A Day In The Life Of An Optimizely Developer. Last night (11th July 2024) I was excited to have attended...

Graham Carr | Jul 16, 2024

Creating Custom Actors for Optimizely Forms

Optimizely Forms is a powerful tool for creating web forms for various purposes such as registrations, job applications, surveys, etc. By default,...

Nahid | Jul 16, 2024

Optimizely SaaS CMS Concepts and Terminologies

Whether you're a new user of Optimizely CMS or a veteran who have been through the evolution of it, the SaaS CMS is bringing some new concepts and...

Patrick Lam | Jul 15, 2024